diff --git a/.circleci/config.yml b/.circleci/config.yml index c2b8ad22988..7322ccc6597 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -401,7 +401,7 @@ jobs: command: | yarn test:parcel mkdir -p dist - yarn compare:apis --isCI --branch-api-dir="/tmp/dist/branch-api" --base-api-dir="/tmp/dist/base-api" | tee dist/ts-diff.txt + yarn compare:apis --isCI --branch-api-dir="/tmp/dist/branch-api" | tee dist/ts-diff.txt - persist_to_workspace: root: dist diff --git a/package.json b/package.json index d4b78ada269..8349e010ac4 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "publish:nightly": "yarn workspaces foreach --all --no-private -t npm publish --tag nightly --access public", "build:api-published": "node scripts/buildPublishedAPI.js", "build:api-branch": "node scripts/buildBranchAPI.js", - "compare:apis": "node scripts/compareAPIs.js", + "compare:apis": "node scripts/compareAPIs.js --compare-v3-s2", "check-apis": "yarn build:api-branch --githash=\"origin/main\" --output=\"base-api\" && yarn build:api-branch && yarn compare:apis", "check-published-apis": "yarn build:api-published && yarn build:api-branch && yarn compare:apis", "bumpVersions": "node scripts/bumpVersions.js", diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index 2ecfc4f8125..f4c32cdfa31 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -283,10 +283,10 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt onKeyDown, onFocus, // 'aria-label': [(node.textValue || undefined), rowAnnouncement].filter(Boolean).join(', '), - 'aria-label': node.textValue || undefined, + 'aria-label': node['aria-label'] || node.textValue || undefined, 'aria-selected': state.selectionManager.canSelectItem(node.key) ? state.selectionManager.isSelected(node.key) : undefined, 'aria-disabled': state.selectionManager.isDisabled(node.key) || undefined, - 'aria-labelledby': descriptionId && node.textValue ? `${getRowId(state, node.key)} ${descriptionId}` : undefined, + 'aria-labelledby': descriptionId && (node['aria-label'] || node.textValue) ? `${getRowId(state, node.key)} ${descriptionId}` : undefined, id: getRowId(state, node.key) }); diff --git a/packages/@react-spectrum/s2/chromatic/Breadcrumbs.stories.tsx b/packages/@react-spectrum/s2/chromatic/Breadcrumbs.stories.tsx index 76996eea018..7fa983a51b1 100644 --- a/packages/@react-spectrum/s2/chromatic/Breadcrumbs.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/Breadcrumbs.stories.tsx @@ -12,7 +12,7 @@ import {Breadcrumb, Breadcrumbs} from '../src'; import {generatePowerset} from '@react-spectrum/story-utils'; -import {Many} from '../stories/Breadcrumbs.stories'; +import {type IMany, Many} from '../stories/Breadcrumbs.stories'; import type {Meta, StoryObj} from '@storybook/react'; import {ReactNode} from 'react'; import {shortName} from './utils'; @@ -27,7 +27,7 @@ const meta: Meta = { export default meta; -export const Dynamic: StoryObj = { +export const Dynamic: StoryObj = { ...Many, parameters: { // TODO: move these options back to meta above once we get strings for ar-AE. This is just to prevent the RTL story's config from actually applying diff --git a/packages/@react-spectrum/s2/package.json b/packages/@react-spectrum/s2/package.json index 36fe4a9ad72..35b7e4baebc 100644 --- a/packages/@react-spectrum/s2/package.json +++ b/packages/@react-spectrum/s2/package.json @@ -164,6 +164,7 @@ "@react-stately/utils": "^3.10.8", "@react-types/dialog": "^3.5.22", "@react-types/grid": "^3.3.6", + "@react-types/overlays": "^3.9.2", "@react-types/provider": "^3.8.13", "@react-types/shared": "^3.32.1", "@react-types/table": "^3.13.4", diff --git a/packages/@react-spectrum/s2/src/ActionBar.tsx b/packages/@react-spectrum/s2/src/ActionBar.tsx index e664e66f636..993d3632e90 100644 --- a/packages/@react-spectrum/s2/src/ActionBar.tsx +++ b/packages/@react-spectrum/s2/src/ActionBar.tsx @@ -15,11 +15,12 @@ import {announce} from '@react-aria/live-announcer'; import {CloseButton} from './CloseButton'; import {ContextValue, SlotProps} from 'react-aria-components'; import {createContext, ForwardedRef, forwardRef, ReactElement, ReactNode, RefObject, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {DOMRef, DOMRefValue, Key} from '@react-types/shared'; +import {DOMProps, DOMRef, DOMRefValue, Key} from '@react-types/shared'; import {FocusScope, useKeyboard} from 'react-aria'; // @ts-ignore import intlMessages from '../intl/*.json'; import {lightDark, style} from '../style' with {type: 'macro'}; +import {StyleProps} from './style-utils' with { type: 'macro' }; import {useControlledState} from '@react-stately/utils'; import {useDOMRef} from '@react-spectrum/utils'; import {useEnterAnimation, useExitAnimation, useObjectRef, useResizeObserver} from '@react-aria/utils'; @@ -74,7 +75,7 @@ const actionBarStyles = style({ } }); -export interface ActionBarProps extends SlotProps { +export interface ActionBarProps extends SlotProps, StyleProps, DOMProps { /** A list of ActionButtons to display. */ children: ReactNode, /** Whether the ActionBar should be displayed with a emphasized style. */ @@ -106,7 +107,8 @@ export const ActionBar = forwardRef(function ActionBar(props: ActionBarProps, re }); const ActionBarInner = forwardRef(function ActionBarInner(props: ActionBarProps & {isExiting: boolean}, ref: ForwardedRef) { - let {isEmphasized, selectedItemCount = 0, children, onClearSelection, isExiting} = props; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let {isEmphasized, selectedItemCount = 0, children, onClearSelection, isExiting, slot, ...otherProps} = props; let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); // Store the last count greater than zero so that we can retain it while rendering the fade-out animation. @@ -158,6 +160,7 @@ const ActionBarInner = forwardRef(function ActionBarInner(props: ActionBarProps
diff --git a/packages/@react-spectrum/s2/src/ActionMenu.tsx b/packages/@react-spectrum/s2/src/ActionMenu.tsx index ed891a42d70..53787c4bb8d 100644 --- a/packages/@react-spectrum/s2/src/ActionMenu.tsx +++ b/packages/@react-spectrum/s2/src/ActionMenu.tsx @@ -29,6 +29,11 @@ export interface ActionMenuProps extends Pick, 'children' | 'items' | 'disabledKeys' | 'onAction'>, Pick, StyleProps, DOMProps, AriaLabelingProps { + /** + * The size of the Trigger and Menu. + * + * @default 'M' + */ menuSize?: 'S' | 'M' | 'L' | 'XL' } diff --git a/packages/@react-spectrum/s2/src/Breadcrumbs.tsx b/packages/@react-spectrum/s2/src/Breadcrumbs.tsx index a8369b9e757..689055cfae9 100644 --- a/packages/@react-spectrum/s2/src/Breadcrumbs.tsx +++ b/packages/@react-spectrum/s2/src/Breadcrumbs.tsx @@ -62,9 +62,9 @@ interface BreadcrumbsStyleProps { // TODO: showRoot?: boolean, } -export interface BreadcrumbsProps extends Omit, 'children' | 'items' | 'style' | 'className' | keyof GlobalDOMAttributes>, BreadcrumbsStyleProps, StyleProps { +export interface BreadcrumbsProps extends Omit, 'children' | 'style' | 'className' | keyof GlobalDOMAttributes>, BreadcrumbsStyleProps, StyleProps { /** The children of the Breadcrumbs. */ - children: ReactNode + children: ReactNode | ((item: T) => ReactNode) } export const BreadcrumbsContext = createContext>, DOMRefValue>>(null); diff --git a/packages/@react-spectrum/s2/src/ContextualHelp.tsx b/packages/@react-spectrum/s2/src/ContextualHelp.tsx index 0590b534438..bf25dedcbb6 100644 --- a/packages/@react-spectrum/s2/src/ContextualHelp.tsx +++ b/packages/@react-spectrum/s2/src/ContextualHelp.tsx @@ -11,6 +11,7 @@ import InfoIcon from '../s2wf-icons/S2_Icon_InfoCircle_20_N.svg'; // @ts-ignore import intlMessages from '../intl/*.json'; import {mergeStyles} from '../style/runtime'; +import {Placement} from '@react-types/overlays'; import {Popover, PopoverDialogProps} from './Popover'; import {space, style} from '../style' with {type: 'macro'}; import {StyleProps} from './style-utils' with { type: 'macro' }; @@ -29,6 +30,11 @@ export interface ContextualHelpProps extends Pick, Pick, ContextualHelpStyleProps, StyleProps, DOMProps, AriaLabelingProps { + /** + * The placement of the popover with respect to the action button. + * @default 'bottom start' + */ + placement?: Placement, /** Contents of the Contextual Help popover. */ children: ReactNode, /** diff --git a/packages/@react-spectrum/s2/src/DatePicker.tsx b/packages/@react-spectrum/s2/src/DatePicker.tsx index 7ffc20b7b9f..e707c911ffe 100644 --- a/packages/@react-spectrum/s2/src/DatePicker.tsx +++ b/packages/@react-spectrum/s2/src/DatePicker.tsx @@ -20,6 +20,7 @@ import { Dialog, FormContext, OverlayTriggerStateContext, + PopoverProps, Provider, TimeValue } from 'react-aria-components'; @@ -27,7 +28,7 @@ import {baseColor, focusRing, fontRelative, space, style} from '../style' with { import {Calendar, CalendarProps, IconContext, TimeField} from '../'; import CalendarIcon from '../s2wf-icons/S2_Icon_Calendar_20_N.svg'; import {controlBorderRadius, field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; -import {createContext, forwardRef, PropsWithChildren, ReactElement, Ref, useContext, useRef, useState} from 'react'; +import {createContext, forwardRef, ReactElement, ReactNode, Ref, useContext, useRef, useState} from 'react'; import {DateInput, DateInputContainer, InvalidIndicator} from './DateField'; import {FieldGroup, FieldLabel, HelpText} from './Field'; import {forwardRefType, GlobalDOMAttributes, HelpTextProps, SpectrumLabelableProps} from '@react-types/shared'; @@ -42,6 +43,7 @@ import {useSpectrumContextProps} from './useSpectrumContextProps'; export interface DatePickerProps extends Omit, 'children' | 'className' | 'style' | keyof GlobalDOMAttributes>, Pick, 'createCalendar' | 'pageBehavior' | 'firstDayOfWeek' | 'isDateUnavailable'>, + Pick, StyleProps, SpectrumLabelableProps, HelpTextProps { @@ -203,7 +205,7 @@ export const DatePicker = /*#__PURE__*/ (forwardRef as forwardRefType)(function - + @@ -238,9 +240,10 @@ export const DatePicker = /*#__PURE__*/ (forwardRef as forwardRefType)(function ); }); -export function CalendarPopover(props: PropsWithChildren): ReactElement { +export function CalendarPopover(props: Omit & {children: ReactNode}): ReactElement { return (
extends Omit, 'children' | 'className' | 'style' | keyof GlobalDOMAttributes>, Pick, 'createCalendar' | 'pageBehavior' | 'firstDayOfWeek' | 'isDateUnavailable'>, + Pick, StyleProps, SpectrumLabelableProps, HelpTextProps { @@ -143,7 +145,7 @@ export const DateRangePicker = /*#__PURE__*/ (forwardRef as forwardRefType)(func
- + diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index 73ebf34ce92..ccdb0228963 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -674,7 +674,7 @@ function DefaultProvider({context, value, children}: {context: React.Context{children}; } -export interface PickerSectionProps extends Omit, keyof GlobalDOMAttributes> {} +export interface PickerSectionProps extends Omit, 'style' | 'className' | keyof GlobalDOMAttributes>, StyleProps {} export function PickerSection(props: PickerSectionProps): ReactNode { let {size} = useContext(InternalPickerContext); return ( diff --git a/packages/@react-spectrum/s2/src/Provider.tsx b/packages/@react-spectrum/s2/src/Provider.tsx index b1c34d198c4..bfbae3cc936 100644 --- a/packages/@react-spectrum/s2/src/Provider.tsx +++ b/packages/@react-spectrum/s2/src/Provider.tsx @@ -13,6 +13,8 @@ import type {ColorScheme, Router} from '@react-types/provider'; import {colorScheme, UnsafeStyles} from './style-utils' with {type: 'macro'}; import {createContext, JSX, ReactNode, useContext} from 'react'; +import {DOMProps} from '@react-types/shared'; +import {filterDOMProps} from '@react-aria/utils'; import {Fonts} from './Fonts'; import {generateDefaultColorSchemeStyles} from './page.macro' with {type: 'macro'}; import {I18nProvider, RouterProvider, useLocale} from 'react-aria-components'; @@ -20,7 +22,7 @@ import {mergeStyles} from '../style/runtime'; import {style} from '../style' with {type: 'macro'}; import {StyleString} from '../style/types'; -export interface ProviderProps extends UnsafeStyles { +export interface ProviderProps extends UnsafeStyles, DOMProps { /** The content of the Provider. */ children: ReactNode, /** @@ -113,6 +115,7 @@ function ProviderInner(props: ProviderProps) { let {locale, direction} = useLocale(); return ( , UnsafeStyles, S2TableProps { +export interface TableViewProps extends Omit, DOMProps, UnsafeStyles, S2TableProps { /** Spectrum-defined styles, returned by the `style()` macro. */ styles?: StylesPropWithHeight } diff --git a/packages/@react-spectrum/s2/src/TagGroup.tsx b/packages/@react-spectrum/s2/src/TagGroup.tsx index 8a67d056c8c..235fd9fcd1f 100644 --- a/packages/@react-spectrum/s2/src/TagGroup.tsx +++ b/packages/@react-spectrum/s2/src/TagGroup.tsx @@ -35,7 +35,7 @@ import {ClearButton} from './ClearButton'; import {Collection, CollectionBuilder} from '@react-aria/collections'; import {control, field, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import {createContext, forwardRef, ReactNode, useContext, useEffect, useMemo, useRef, useState} from 'react'; -import {DOMRef, DOMRefValue, GlobalDOMAttributes, HelpTextProps, Node, SpectrumLabelableProps} from '@react-types/shared'; +import {DOMRef, DOMRefValue, GlobalDOMAttributes, HelpTextProps, LabelableProps, Node, SpectrumLabelableProps} from '@react-types/shared'; import {FieldLabel, helpTextStyles} from './Field'; import {flushSync} from 'react-dom'; import {FormContext, useFormProps} from './Form'; @@ -52,7 +52,7 @@ import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useSpectrumContextProps} from './useSpectrumContextProps'; // Get types from RSP and extend those? -export interface TagProps extends Omit { +export interface TagProps extends Omit, LabelableProps { /** The children of the tag. */ children: ReactNode } diff --git a/packages/@react-spectrum/s2/src/ToggleButtonGroup.tsx b/packages/@react-spectrum/s2/src/ToggleButtonGroup.tsx index 79a972a40da..022f0bc0119 100644 --- a/packages/@react-spectrum/s2/src/ToggleButtonGroup.tsx +++ b/packages/@react-spectrum/s2/src/ToggleButtonGroup.tsx @@ -13,10 +13,10 @@ import {ActionButtonGroupProps, actionGroupStyle} from './ActionButtonGroup'; import {ContextValue, ToggleButtonGroup as RACToggleButtonGroup, ToggleButtonGroupProps as RACToggleButtonGroupProps} from 'react-aria-components'; import {createContext, ForwardedRef, forwardRef} from 'react'; -import {GlobalDOMAttributes} from '@react-types/shared'; +import {DOMProps, GlobalDOMAttributes} from '@react-types/shared'; import {useSpectrumContextProps} from './useSpectrumContextProps'; -export interface ToggleButtonGroupProps extends ActionButtonGroupProps, Omit { +export interface ToggleButtonGroupProps extends ActionButtonGroupProps, Omit, DOMProps { /** Whether the button should be displayed with an [emphasized style](https://spectrum.adobe.com/page/action-button/#Emphasis). */ isEmphasized?: boolean } diff --git a/packages/@react-spectrum/s2/src/Tooltip.tsx b/packages/@react-spectrum/s2/src/Tooltip.tsx index 95e07643f91..6f4ef6421ac 100644 --- a/packages/@react-spectrum/s2/src/Tooltip.tsx +++ b/packages/@react-spectrum/s2/src/Tooltip.tsx @@ -23,7 +23,7 @@ import {centerPadding, colorScheme, UnsafeStyles} from './style-utils' with {typ import {ColorScheme} from '@react-types/provider'; import {ColorSchemeContext} from './Provider'; import {createContext, forwardRef, MutableRefObject, ReactNode, useCallback, useContext, useState} from 'react'; -import {DOMRef, GlobalDOMAttributes} from '@react-types/shared'; +import {DOMProps, DOMRef, GlobalDOMAttributes} from '@react-types/shared'; import {style} from '../style' with {type: 'macro'}; import {useDOMRef} from '@react-spectrum/utils'; @@ -35,10 +35,16 @@ export interface TooltipTriggerProps extends Omit, UnsafeStyles { +export interface TooltipProps extends Omit, DOMProps, UnsafeStyles { /** The content of the tooltip. */ children: ReactNode } @@ -139,7 +145,7 @@ export const Tooltip = forwardRef(function Tooltip(props: TooltipProps, ref: DOM let { containerPadding, crossOffset, - offset, + offset = 4, placement = 'top', shouldFlip } = useContext(InternalTooltipTriggerContext); diff --git a/packages/@react-spectrum/s2/stories/Breadcrumbs.stories.tsx b/packages/@react-spectrum/s2/stories/Breadcrumbs.stories.tsx index ab5110309d2..0a6a6affc8f 100644 --- a/packages/@react-spectrum/s2/stories/Breadcrumbs.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Breadcrumbs.stories.tsx @@ -11,7 +11,7 @@ */ import {action} from '@storybook/addon-actions'; -import {Breadcrumb, Breadcrumbs} from '../src'; +import {Breadcrumb, Breadcrumbs, BreadcrumbsProps} from '../src'; import type {Meta, StoryObj} from '@storybook/react'; const meta: Meta = { @@ -57,24 +57,31 @@ export const Example: Story = { ) }; -let items = [ +interface Item { + id: string, + name: string +} +let items: Item[] = [ {id: 'home', name: 'Home'}, {id: 'react-aria', name: 'React Aria'}, {id: 'breadcrumbs', name: 'Breadcrumbs'} ]; -export const WithActions: Story = { - render: (args: any) => ( - - {item => ( - - {item.name} - - )} - - ) + +const BreadcrumbsExampleDynamic = (args: BreadcrumbsProps) => ( + + {item => ( + + {item.name} + + )} + +); + +export const WithActions: StoryObj = { + render: BreadcrumbsExampleDynamic }; -let manyItems = [ +let manyItems: Item[] = [ {id: 'Folder 1', name: 'The quick brown fox jumps over'}, {id: 'Folder 2', name: 'My Documents'}, {id: 'Folder 3', name: 'Kangaroos jump high'}, @@ -83,9 +90,9 @@ let manyItems = [ {id: 'Folder 6', name: 'Wattle trees'}, {id: 'Folder 7', name: 'April 7'} ]; - -export const Many: Story = { - render: (args: any) => ( +export type IMany = typeof BreadcrumbsExampleDynamic; +export const Many: StoryObj = { + render: (args: BreadcrumbsProps) => (
{item => ( @@ -98,7 +105,10 @@ export const Many: Story = { ) }; -let manyItemsWithLinks = [ +interface ItemWithLink extends Item { + href: string +} +let manyItemsWithLinks: ItemWithLink[] = [ {id: 'Folder 1', name: 'The quick brown fox jumps over', href: '/folder1'}, {id: 'Folder 2', name: 'My Documents', href: '/folder2'}, {id: 'Folder 3', name: 'Kangaroos jump high', href: '/folder3'}, @@ -108,16 +118,16 @@ let manyItemsWithLinks = [ {id: 'Folder 7', name: 'April 7', href: '/folder7'} ]; -export const ManyWithLinks: Story = { - render: (args: any) => ( -
- - {item => ( - - {item.name} - - )} - -
- ) +const BreadcrumbsExampleDynamicWithLinks = (args: BreadcrumbsProps) => ( + + {item => ( + + {item.name} + + )} + +); + +export const ManyWithLinks: StoryObj = { + render: BreadcrumbsExampleDynamicWithLinks }; diff --git a/packages/@react-spectrum/s2/test/TagGroup.test.tsx b/packages/@react-spectrum/s2/test/TagGroup.test.tsx new file mode 100644 index 00000000000..9f732e2f2dc --- /dev/null +++ b/packages/@react-spectrum/s2/test/TagGroup.test.tsx @@ -0,0 +1,39 @@ +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import React from 'react'; +import {render} from '@react-spectrum/test-utils-internal'; +import {Tag, TagGroup} from '../'; + +let TestTagGroup = ({tagGroupProps, itemProps}) => ( + + Cat + Dog + Kangaroo + +); + +let renderTagGroup = (tagGroupProps = {}, itemProps = {}) => render(); + +describe('TagGroup', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + it('should aria label on tags', () => { + let {getAllByRole} = renderTagGroup({label: 'TagGroup label'}, {'aria-label': 'Test'}); + + for (let row of getAllByRole('row')) { + expect(row).toHaveAttribute('aria-label', 'Test'); + } + }); +}); diff --git a/packages/@react-spectrum/tree/test/TreeView.test.tsx b/packages/@react-spectrum/tree/test/TreeView.test.tsx index e7658edd446..f87c26f13a3 100644 --- a/packages/@react-spectrum/tree/test/TreeView.test.tsx +++ b/packages/@react-spectrum/tree/test/TreeView.test.tsx @@ -518,7 +518,7 @@ describe('Tree', () => { let rows = getAllByRole('row'); expect(rows).toHaveLength(1); - expect(rows[0]).toHaveAttribute('aria-label', 'Test'); + expect(rows[0]).toHaveAttribute('aria-label', 'test row'); }); describe('general interactions', () => { diff --git a/packages/@react-types/combobox/src/index.d.ts b/packages/@react-types/combobox/src/index.d.ts index 5b96371b82e..3558c274dc8 100644 --- a/packages/@react-types/combobox/src/index.d.ts +++ b/packages/@react-types/combobox/src/index.d.ts @@ -84,7 +84,7 @@ export interface SpectrumComboBoxProps extends SpectrumTextInputBase, Omit, SpectrumFieldValidation, InputDOMProps, StyleProps, SpectrumLabelableProps { /** Whether the numberfield should be displayed with a quiet style. */ isQuiet?: boolean, - /** Whether to hide the increment and decrement buttons. */ + /** + * Whether to hide the increment and decrement buttons. + * @default false + */ hideStepper?: boolean } diff --git a/packages/@react-types/tabs/src/index.d.ts b/packages/@react-types/tabs/src/index.d.ts index a24d94a8338..9a20acc7ef9 100644 --- a/packages/@react-types/tabs/src/index.d.ts +++ b/packages/@react-types/tabs/src/index.d.ts @@ -60,7 +60,7 @@ export interface AriaTabPanelProps extends Omit, AriaLabelingPro id?: Key } -export interface SpectrumTabsProps extends AriaTabListBase, Omit, DOMProps, StyleProps { +export interface SpectrumTabsProps extends AriaTabListBase, Omit, DOMProps, StyleProps { /** The children of the `` element. Should include `` and `` elements. */ children: ReactNode, /** The item objects for each tab, for dynamic collections. */ diff --git a/packages/dev/s2-docs/pages/s2/ToggleButtonGroup.mdx b/packages/dev/s2-docs/pages/s2/ToggleButtonGroup.mdx index dbec0acb6ed..124ba7cec64 100644 --- a/packages/dev/s2-docs/pages/s2/ToggleButtonGroup.mdx +++ b/packages/dev/s2-docs/pages/s2/ToggleButtonGroup.mdx @@ -10,7 +10,7 @@ export const tags = ['toggle', 'btn']; {docs.exports.ToggleButtonGroup.description} -```tsx render docs={docs.exports.ToggleButtonGroup} links={docs.links} props={['selectionMode', 'orientation', 'size', 'density', 'staticColor', 'isQuiet', 'isJustified', 'isDisabled']} type="s2" +```tsx render docs={docs.exports.ToggleButtonGroup} links={docs.links} props={['selectionMode', 'orientation', 'size', 'density', 'staticColor', 'isQuiet', 'isJustified', 'isDisabled', 'isEmphasized']} type="s2" "use client"; import {ToggleButtonGroup, ToggleButton, Text} from '@react-spectrum/s2'; import TextBold from '@react-spectrum/s2/icons/TextBold'; diff --git a/packages/react-aria-components/src/Breadcrumbs.tsx b/packages/react-aria-components/src/Breadcrumbs.tsx index 21a0929ace6..9149c9c1ab7 100644 --- a/packages/react-aria-components/src/Breadcrumbs.tsx +++ b/packages/react-aria-components/src/Breadcrumbs.tsx @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ import {AriaBreadcrumbsProps, useBreadcrumbs} from 'react-aria'; +import {AriaLabelingProps, forwardRefType, GlobalDOMAttributes, Key} from '@react-types/shared'; import { ClassNameOrFunction, ContextValue, @@ -23,12 +24,11 @@ import { import {Collection, CollectionBuilder, CollectionNode, createLeafComponent} from '@react-aria/collections'; import {CollectionProps, CollectionRendererContext} from './Collection'; import {filterDOMProps, mergeProps} from '@react-aria/utils'; -import {forwardRefType, GlobalDOMAttributes, Key} from '@react-types/shared'; import {LinkContext} from './Link'; import {Node} from 'react-stately'; import React, {createContext, ForwardedRef, forwardRef, useContext} from 'react'; -export interface BreadcrumbsProps extends Omit, 'disabledKeys'>, AriaBreadcrumbsProps, StyleProps, SlotProps, GlobalDOMAttributes { +export interface BreadcrumbsProps extends Omit, 'disabledKeys'>, AriaBreadcrumbsProps, StyleProps, SlotProps, AriaLabelingProps, GlobalDOMAttributes { /** * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the element. * @default 'react-aria-Breadcrumbs' @@ -49,7 +49,7 @@ export const Breadcrumbs = /*#__PURE__*/ (forwardRef as forwardRefType)(function [props, ref] = useContextProps(props, ref, BreadcrumbsContext); let {CollectionRoot} = useContext(CollectionRendererContext); let {navProps} = useBreadcrumbs(props); - let DOMProps = filterDOMProps(props, {global: true}); + let DOMProps = filterDOMProps(props, {global: true, labelable: true}); return ( }> @@ -82,7 +82,7 @@ export interface BreadcrumbRenderProps { isDisabled: boolean } -export interface BreadcrumbProps extends RenderProps, GlobalDOMAttributes { +export interface BreadcrumbProps extends RenderProps, AriaLabelingProps, GlobalDOMAttributes { /** * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the element. A function may be provided to compute the class based on component state. * @default 'react-aria-Breadcrumb' @@ -116,7 +116,7 @@ export const Breadcrumb = /*#__PURE__*/ createLeafComponent(BreadcrumbNode, func defaultClassName: 'react-aria-Breadcrumb' }); - let DOMProps = filterDOMProps(props as any, {global: true}); + let DOMProps = filterDOMProps(props as any, {global: true, labelable: true}); delete DOMProps.id; return ( diff --git a/packages/react-aria-components/src/Disclosure.tsx b/packages/react-aria-components/src/Disclosure.tsx index c4bb05d10aa..6bfebd7db32 100644 --- a/packages/react-aria-components/src/Disclosure.tsx +++ b/packages/react-aria-components/src/Disclosure.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {AriaDisclosureProps, useDisclosure, useFocusRing} from 'react-aria'; +import {AriaDisclosureProps, LabelAriaProps, useDisclosure, useFocusRing} from 'react-aria'; import {ButtonContext} from './Button'; import { ClassNameOrFunction, @@ -206,7 +206,7 @@ export interface DisclosurePanelRenderProps { isFocusVisibleWithin: boolean } -export interface DisclosurePanelProps extends RenderProps, DOMProps, GlobalDOMAttributes { +export interface DisclosurePanelProps extends RenderProps, DOMProps, LabelAriaProps, GlobalDOMAttributes { /** * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the element. A function may be provided to compute the class based on component state. * @default 'react-aria-DisclosurePanel' @@ -240,7 +240,7 @@ export const DisclosurePanel = /*#__PURE__*/ (forwardRef as forwardRefType)(func isFocusVisibleWithin } }); - let DOMProps = filterDOMProps(props, {global: true}); + let DOMProps = filterDOMProps(props, {global: true, labelable: true}); return (
{ }); it('should support DOM props', () => { - let {getByRole, getAllByRole} = renderBreadcrumbs({'data-foo': 'bar'}, {'data-bar': 'foo'}); + let {getByRole, getAllByRole} = renderBreadcrumbs({'data-foo': 'bar', 'aria-label': 'test group'}, {'data-bar': 'foo', 'aria-label': 'test item'}); let breadcrumbs = getByRole('list'); expect(breadcrumbs).toHaveAttribute('data-foo', 'bar'); + expect(breadcrumbs).toHaveAttribute('aria-label', 'test group'); for (let item of getAllByRole('listitem')) { expect(item).toHaveAttribute('data-bar', 'foo'); + expect(item).toHaveAttribute('aria-label', 'test item'); } }); diff --git a/packages/react-aria-components/test/Disclosure.test.js b/packages/react-aria-components/test/Disclosure.test.js index 1ebfb13e91e..00411840f50 100644 --- a/packages/react-aria-components/test/Disclosure.test.js +++ b/packages/react-aria-components/test/Disclosure.test.js @@ -77,6 +77,23 @@ describe('Disclosure', () => { expect(disclosure).toHaveAttribute('data-foo', 'bar'); }); + it('should support aria label', () => { + const {container, getByRole} = render( + + + + + +

Content

+
+
+ ); + const heading = getByRole('heading'); + expect(heading).toHaveAttribute('aria-label', 'Test disclosure heading'); + const panel = container.querySelector('.react-aria-DisclosurePanel'); + expect(panel).toHaveAttribute('aria-label', 'Test disclosure panel'); + }); + it('should support disabled state', () => { const {getByTestId, getByRole, queryByText} = render( diff --git a/packages/react-aria-components/test/TagGroup.test.js b/packages/react-aria-components/test/TagGroup.test.js index 2dad220810a..adc4f07135b 100644 --- a/packages/react-aria-components/test/TagGroup.test.js +++ b/packages/react-aria-components/test/TagGroup.test.js @@ -527,9 +527,9 @@ describe('TagGroup', () => { let {getAllByRole} = renderTagGroup({selectionMode: 'single', onSelectionChange}); let items = getAllByRole('row'); - await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); expect(onSelectionChange).toBeCalledTimes(1); - + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); expect(onSelectionChange).toBeCalledTimes(1); }); @@ -539,9 +539,9 @@ describe('TagGroup', () => { let {getAllByRole} = renderTagGroup({selectionMode: 'single', onSelectionChange, shouldSelectOnPressUp: false}); let items = getAllByRole('row'); - await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); expect(onSelectionChange).toBeCalledTimes(1); - + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); expect(onSelectionChange).toBeCalledTimes(1); }); @@ -551,9 +551,9 @@ describe('TagGroup', () => { let {getAllByRole} = renderTagGroup({selectionMode: 'single', onSelectionChange, shouldSelectOnPressUp: true}); let items = getAllByRole('row'); - await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); expect(onSelectionChange).toBeCalledTimes(0); - + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); expect(onSelectionChange).toBeCalledTimes(1); }); @@ -572,7 +572,7 @@ describe('TagGroup', () => { let {getByRole} = renderTagGroup({selectionMode: 'multiple'}, {}, {onPressStart, onPressEnd, onPress, onClick}); let tester = testUtilUser.createTester('GridList', {root: getByRole('grid')}); await tester.triggerRowAction({row: 1, interactionType}); - + expect(onPressStart).toHaveBeenCalledTimes(1); expect(onPressEnd).toHaveBeenCalledTimes(1); expect(onPress).toHaveBeenCalledTimes(1); diff --git a/scripts/compareAPIs.js b/scripts/compareAPIs.js index 84ca3d11ce4..2191f33dda4 100644 --- a/scripts/compareAPIs.js +++ b/scripts/compareAPIs.js @@ -29,8 +29,8 @@ const args = parseArgs({ 'branch-api-dir': { type: 'string' }, - 'base-api-dir': { - type: 'string' + 'compare-v3-s2': { + type: 'boolean' } } }); @@ -42,6 +42,87 @@ let dependantOnLinks = new Map(); let currentlyProcessing = ''; let depth = 0; +// Style props from @react-spectrum/utils/src/styleProps.ts that should be excluded from diffs +// These are baseStyleProps and viewStyleProps +let stylePropsToExclude = new Set([ + // baseStyleProps + 'margin', 'marginStart', 'marginEnd', 'marginTop', 'marginBottom', 'marginX', 'marginY', + 'width', 'height', 'minWidth', 'minHeight', 'maxWidth', 'maxHeight', + 'isHidden', 'alignSelf', 'justifySelf', + 'position', 'zIndex', 'top', 'bottom', 'start', 'end', 'left', 'right', + 'order', 'flex', 'flexGrow', 'flexShrink', 'flexBasis', + 'gridArea', 'gridColumn', 'gridColumnEnd', 'gridColumnStart', 'gridRow', 'gridRowEnd', 'gridRowStart', + // viewStyleProps (additional to baseStyleProps) + 'backgroundColor', + 'borderWidth', 'borderStartWidth', 'borderEndWidth', 'borderLeftWidth', 'borderRightWidth', + 'borderTopWidth', 'borderBottomWidth', 'borderXWidth', 'borderYWidth', + 'borderColor', 'borderStartColor', 'borderEndColor', 'borderLeftColor', 'borderRightColor', + 'borderTopColor', 'borderBottomColor', 'borderXColor', 'borderYColor', + 'borderRadius', 'borderTopStartRadius', 'borderTopEndRadius', 'borderBottomStartRadius', + 'borderBottomEndRadius', 'borderTopLeftRadius', 'borderTopRightRadius', + 'borderBottomLeftRadius', 'borderBottomRightRadius', + 'padding', 'paddingStart', 'paddingEnd', 'paddingLeft', 'paddingRight', + 'paddingTop', 'paddingBottom', 'paddingX', 'paddingY', + 'overflow' +]); + +// Mapping of S2 component names to their v3 package locations +// Format: 'S2ComponentName': {package: '@react-spectrum/packagename', component: 'ComponentName'} +let s2ToV3ComponentMap = { + 'ActionButton': {package: '@react-spectrum/button', component: 'ActionButton'}, + 'ActionButtonGroup': {package: '@react-spectrum/actiongroup', component: 'ActionGroup'}, + 'ActionMenu': {package: '@react-spectrum/menu', component: 'ActionMenu'}, + 'AlertDialog': {package: '@react-spectrum/dialog', component: 'AlertDialog'}, + 'Breadcrumb': {package: '@react-stately/collections', component: 'Item'}, + 'CheckboxGroup': {package: '@react-spectrum/checkbox', component: 'CheckboxGroup'}, + 'ColorArea': {package: '@react-spectrum/color', component: 'ColorArea'}, + 'ColorField': {package: '@react-spectrum/color', component: 'ColorField'}, + 'ColorSlider': {package: '@react-spectrum/color', component: 'ColorSlider'}, + 'ColorWheel': {package: '@react-spectrum/color', component: 'ColorWheel'}, + 'Column': {package: '@react-spectrum/table', component: 'Column'}, + 'ComboBoxItem': {package: '@react-stately/collections', component: 'Item'}, + 'ComboBoxSection': {package: '@react-stately/collections', component: 'Section'}, + 'Content': {package: '@react-spectrum/view', component: 'Content'}, + 'DateField': {package: '@react-spectrum/datepicker', component: 'DateField'}, + 'DateRangePicker': {package: '@react-spectrum/datepicker', component: 'DateRangePicker'}, + 'DialogContainer': {package: '@react-spectrum/dialog', component: 'DialogContainer'}, + 'DialogTrigger': {package: '@react-spectrum/dialog', component: 'DialogTrigger'}, + 'Disclosure': {package: '@react-spectrum/accordion', component: 'Disclosure'}, + 'DisclosureHeader': {package: '@react-spectrum/accordion', component: 'DisclosureTitle'}, + 'DisclosurePanel': {package: '@react-spectrum/accordion', component: 'DisclosurePanel'}, + 'DisclosureTitle': {package: '@react-spectrum/accordion', component: 'DisclosureTitle'}, + 'Footer': {package: '@react-spectrum/view', component: 'Footer'}, + 'Header': {package: '@react-spectrum/view', component: 'Header'}, + 'Heading': {package: '@react-spectrum/text', component: 'Heading'}, + 'Keyboard': {package: '@react-spectrum/text', component: 'Keyboard'}, + 'MenuItem': {package: '@react-stately/collections', component: 'Item'}, + 'MenuSection': {package: '@react-stately/collections', component: 'Section'}, + 'PickerItem': {package: '@react-stately/collections', component: 'Item'}, + 'PickerSection': {package: '@react-stately/collections', component: 'Section'}, + 'ProgressBar': {package: '@react-spectrum/progress', component: 'ProgressBar'}, + 'ProgressCircle': {package: '@react-spectrum/progress', component: 'ProgressCircle'}, + 'RadioGroup': {package: '@react-spectrum/radio', component: 'RadioGroup'}, + 'RangeCalendar': {package: '@react-spectrum/calendar', component: 'RangeCalendar'}, + 'RangeSlider': {package: '@react-spectrum/slider', component: 'RangeSlider'}, + 'Row': {package: '@react-spectrum/table', component: 'Row'}, + 'Tab': {package: '@react-spectrum/tabs', component: 'Item'}, + 'TabList': {package: '@react-spectrum/tabs', component: 'TabList'}, + 'TabPanel': {package: '@react-spectrum/tabs', component: 'Item'}, + 'TableBody': {package: '@react-spectrum/table', component: 'TableBody'}, + 'TableHeader': {package: '@react-spectrum/table', component: 'TableHeader'}, + 'TableView': {package: '@react-spectrum/table', component: 'TableView'}, + 'TagGroup': {package: '@react-spectrum/tag', component: 'TagGroup'}, + 'Tag': {package: '@react-stately/collections', component: 'Item'}, + 'TextArea': {package: '@react-spectrum/textfield', component: 'TextArea'}, + 'TimeField': {package: '@react-spectrum/datepicker', component: 'TimeField'}, + 'ToggleButton': {package: '@react-spectrum/button', component: 'ToggleButton'}, + 'ToggleButtonGroup': {package: '@react-spectrum/actiongroup', component: 'ActionGroup'}, + 'TooltipTrigger': {package: '@react-spectrum/tooltip', component: 'TooltipTrigger'}, + 'TreeView': {package: '@react-spectrum/tree', component: 'TreeView'}, + 'TreeViewItem': {package: '@react-spectrum/tree', component: 'TreeViewItem'}, + 'TreeViewItemContent': {package: '@react-spectrum/tree', component: 'TreeViewItemContent'} +}; + compare().catch(err => { console.error(err.stack); process.exit(1); @@ -66,59 +147,196 @@ function readJsonSync(filePath) { */ async function compare() { let branchDir = args.values['branch-api-dir'] || path.join(__dirname, '..', 'dist', 'branch-api'); - let publishedDir = args.values['base-api-dir'] || path.join(__dirname, '..', 'dist', 'base-api'); - if (!(fs.existsSync(branchDir) && fs.existsSync(publishedDir))) { - console.log(chalk.redBright(`you must have both a branchDir ${branchDir} and baseDir ${publishedDir}`)); - return; - } - - let branchAPIs = fg.sync(`${branchDir}/**/api.json`); - let publishedAPIs = fg.sync(`${publishedDir}/**/api.json`); let pairs = []; - // find all matching pairs based on what's been published - for (let pubApi of publishedAPIs) { - let pubApiPath = pubApi.split(path.sep); - let pkgJson = readJsonSync(path.join('/', ...pubApiPath.slice(0, pubApiPath.length - 2), 'package.json')); - let name = pkgJson.name; - let sharedPath = path.join(name, 'dist', 'api.json'); - let found = false; - for (let branchApi of branchAPIs) { - if (branchApi.includes(sharedPath)) { - found = true; - pairs.push({pubApi, branchApi}); - break; + if (args.values['compare-v3-s2']) { + // Compare v3 vs s2 components within branch-api only + if (!fs.existsSync(branchDir)) { + console.log(chalk.redBright(`branchDir ${branchDir} does not exist`)); + return; + } + + let branchAPIs = fg.sync(`${branchDir}/**/api.json`); + + // Find the s2 API file + let s2Api = branchAPIs.find(api => api.includes('@react-spectrum/s2/dist/api.json')); + if (!s2Api) { + console.log(chalk.redBright('Could not find @react-spectrum/s2 API')); + return; + } + + let s2ApiData = getAPI(s2Api); + let s2ComponentsWithoutMatch = []; + let v3ComponentsWithoutMatch = []; + let matchedV3Packages = new Set(); + + // For each export in s2, try to find matching v3 component + for (let exportName of Object.keys(s2ApiData.exports)) { + // Skip hooks (exports starting with 'use') + if (exportName.startsWith('use')) { + continue; + } + + let exportItem = s2ApiData.exports[exportName]; + // Only process components + if (exportItem?.type !== 'component') { + continue; + } + + // Find matching v3 component package + let v3PackageName, v3ComponentName; + + // Check if there's a custom mapping for this component + if (s2ToV3ComponentMap[exportName]) { + v3PackageName = s2ToV3ComponentMap[exportName].package; + v3ComponentName = s2ToV3ComponentMap[exportName].component; + } else { + // Default: use lowercase component name as package name + let componentName = exportName.toLowerCase(); + v3PackageName = `@react-spectrum/${componentName}`; + v3ComponentName = exportName; + } + + let v3Api = branchAPIs.find(api => api.includes(`${v3PackageName}/dist/api.json`)); + + if (v3Api) { + // Check if it's not a react-aria package + let v3PackageJson = readJsonSync(path.join(v3Api, '..', '..', 'package.json')); + if (!v3PackageJson.name.includes('@react-aria/')) { + pairs.push({ + v3Api, + s2Api, + componentName: exportName, + v3PackageName: v3PackageJson.name, + v3ComponentName: v3ComponentName + }); + matchedV3Packages.add(v3PackageJson.name); + } + } else { + s2ComponentsWithoutMatch.push(exportName); + } + } + + // Find v3 @react-spectrum components that weren't matched + for (let apiPath of branchAPIs) { + if (!apiPath.includes('@react-spectrum/') || apiPath.includes('@react-spectrum/s2/')) { + continue; + } + + let pkgJsonPath = path.join(apiPath, '..', '..', 'package.json'); + if (!fs.existsSync(pkgJsonPath)) { + continue; + } + + let pkgJson = readJsonSync(pkgJsonPath); + + // Skip if not a react-spectrum package, or if it's react-aria + if (!pkgJson.name.startsWith('@react-spectrum/') || pkgJson.name.includes('@react-aria/')) { + continue; + } + + // Skip if it was already matched + if (matchedV3Packages.has(pkgJson.name)) { + continue; + } + + // Check if it has any component exports (not just hooks) + let apiData = getAPI(apiPath); + let hasComponentExport = Object.values(apiData.exports || {}).some(exp => + exp?.type === 'component' + ); + + if (hasComponentExport) { + v3ComponentsWithoutMatch.push(pkgJson.name); } } - if (!found) { - pairs.push({pubApi, branchApi: null}); + + // Log unmatched components + if (s2ComponentsWithoutMatch.length > 0 || v3ComponentsWithoutMatch.length > 0) { + console.log('\n' + chalk.yellow('='.repeat(60))); + console.log(chalk.yellow.bold('Unmatched Components Report')); + console.log(chalk.yellow('='.repeat(60))); + + if (s2ComponentsWithoutMatch.length > 0) { + console.log('\n' + chalk.cyan.bold(`S2 components without v3 match (${s2ComponentsWithoutMatch.length}):`)); + s2ComponentsWithoutMatch.sort().forEach(name => { + console.log(chalk.cyan(` - ${name}`)); + }); + } + + if (v3ComponentsWithoutMatch.length > 0) { + console.log('\n' + chalk.magenta.bold(`v3 components without S2 match (${v3ComponentsWithoutMatch.length}):`)); + v3ComponentsWithoutMatch.sort().forEach(name => { + console.log(chalk.magenta(` - ${name}`)); + }); + } + + console.log('\n' + chalk.green.bold(`Successfully matched: ${pairs.length} components`)); + console.log(chalk.yellow('='.repeat(60)) + '\n'); + } + } else { + // Original comparison logic: branch vs published + let publishedDir = args.values['base-api-dir'] || path.join(__dirname, '..', 'dist', 'base-api'); + if (!(fs.existsSync(branchDir) && fs.existsSync(publishedDir))) { + console.log(chalk.redBright(`you must have both a branchDir ${branchDir} and baseDir ${publishedDir}`)); + return; } - } - // don't care about private APIs, but we do care if we're about to publish a new one - for (let branchApi of branchAPIs) { - let branchApiPath = branchApi.split(path.sep); - let pkgJson = readJsonSync(path.join('/', ...branchApiPath.slice(0, branchApiPath.length - 2), 'package.json')); - let name = pkgJson.name; - let sharedPath = path.join(name, 'dist', 'api.json'); - let found = false; + let branchAPIs = fg.sync(`${branchDir}/**/api.json`); + let publishedAPIs = fg.sync(`${publishedDir}/**/api.json`); + + // find all matching pairs based on what's been published for (let pubApi of publishedAPIs) { - if (pubApi.includes(sharedPath)) { - found = true; - break; + let pubApiPath = pubApi.split(path.sep); + let pkgJson = readJsonSync(path.join('/', ...pubApiPath.slice(0, pubApiPath.length - 2), 'package.json')); + let name = pkgJson.name; + let sharedPath = path.join(name, 'dist', 'api.json'); + let found = false; + for (let branchApi of branchAPIs) { + if (branchApi.includes(sharedPath)) { + found = true; + pairs.push({pubApi, branchApi}); + break; + } + } + if (!found) { + pairs.push({pubApi, branchApi: null}); } } - let json = readJsonSync(path.join(branchApi, '..', '..', 'package.json')); - if (!found && !json.private) { - pairs.push({pubApi: null, branchApi}); + + // don't care about private APIs, but we do care if we're about to publish a new one + for (let branchApi of branchAPIs) { + let branchApiPath = branchApi.split(path.sep); + let pkgJson = readJsonSync(path.join('/', ...branchApiPath.slice(0, branchApiPath.length - 2), 'package.json')); + let name = pkgJson.name; + let sharedPath = path.join(name, 'dist', 'api.json'); + let found = false; + for (let pubApi of publishedAPIs) { + if (pubApi.includes(sharedPath)) { + found = true; + break; + } + } + let json = readJsonSync(path.join(branchApi, '..', '..', 'package.json')); + if (!found && !json.private) { + pairs.push({pubApi: null, branchApi}); + } } } let allDiffs = {}; + let skippedComparisons = []; for (let pair of pairs) { - let {diffs, name} = getDiff(pair); - if (diffs && diffs.length > 0) { - allDiffs[name] = diffs; + let result = getDiff(pair); + if (result.skipped) { + skippedComparisons.push({ + name: result.name, + reason: result.reason, + componentName: pair.componentName, + v3Package: pair.v3PackageName + }); + } else if (result.diffs && result.diffs.length > 0) { + allDiffs[result.name] = result.diffs; } } @@ -179,6 +397,23 @@ ${changes.join('\n')} console.log(message); }); } + + // Report skipped comparisons + if (skippedComparisons.length > 0) { + console.log('\n' + chalk.yellow('='.repeat(60))); + console.log(chalk.yellow.bold('Skipped Comparisons Report')); + console.log(chalk.yellow('='.repeat(60))); + console.log(chalk.yellow(`\n${skippedComparisons.length} component(s) could not be compared:\n`)); + + skippedComparisons.forEach(({componentName, v3Package, reason}) => { + console.log(chalk.cyan(` ${componentName}:`)); + console.log(chalk.gray(` v3 package: ${v3Package}`)); + console.log(chalk.gray(` reason: ${reason}`)); + console.log(''); + }); + + console.log(chalk.yellow('='.repeat(60)) + '\n'); + } } // takes an interface name and follows all the interfaces dependencies to @@ -253,23 +488,101 @@ function getAPI(filePath) { // bulk of the logic, read the api files, rebuild the interfaces, diff those reconstructions function getDiff(pair) { let name; - if (pair.branchApi) { - name = pair.branchApi.replace(/.*branch-api/, ''); + let publishedApi, branchApi, allExportNames; + + if (args.values['compare-v3-s2']) { + // v3 vs s2 comparison + name = `${pair.componentName} (v3: ${pair.v3PackageName} vs s2)`; + + if (args.values.package && !args.values.package.includes(pair.componentName)) { + return {diffs: [], name}; + } + + if (args.values.verbose) { + console.log(`diffing ${name}`); + } + + console.log(chalk.blue(`\nComparing ${pair.componentName}:`)); + console.log(chalk.gray(` v3 API: ${pair.v3Api}`)); + console.log(chalk.gray(` s2 API: ${pair.s2Api}`)); + + // Get the v3 component API + let v3ApiData = getAPI(pair.v3Api); + // Get the s2 API and extract just the specific component + let s2ApiData = getAPI(pair.s2Api); + + // Create filtered APIs with only the component we care about + publishedApi = { + exports: {}, + links: v3ApiData.links || {} + }; + branchApi = { + exports: {}, + links: s2ApiData.links || {} + }; + + // Find the main export in v3 (usually the component name) + // In v3, the component might be the default export or match the package name + let v3ComponentName = pair.v3ComponentName || pair.componentName; + let v3ComponentData = null; + + console.log(chalk.gray(` Looking for v3 component: ${v3ComponentName}`)); + console.log(chalk.gray(` Available v3 exports: ${Object.keys(v3ApiData.exports).join(', ')}`)); + + if (v3ApiData.exports[v3ComponentName]) { + v3ComponentData = v3ApiData.exports[v3ComponentName]; + console.log(chalk.green(` ✓ Found v3 component: ${v3ComponentName} (type: ${v3ComponentData?.type})`)); + } else { + console.log(chalk.red(` ✗ v3 component ${v3ComponentName} not found - skipping comparison`)); + // Return empty diffs to skip this comparison + return {diffs: [], name, skipped: true, reason: 'v3 component not found'}; + } + + // Use a common key for comparison (the S2 component name) + // Normalize the component name so both v3 and s2 use the same name + if (v3ComponentData) { + // Clone the component data and set the name to match the S2 component name + v3ComponentData = JSON.parse(JSON.stringify(v3ComponentData)); + v3ComponentData.name = pair.componentName; + publishedApi.exports[pair.componentName] = v3ComponentData; + } + + // Get the s2 component - also clone and normalize the name + console.log(chalk.gray(` Looking for s2 component: ${pair.componentName}`)); + let s2ComponentData = s2ApiData.exports[pair.componentName]; + if (s2ComponentData) { + console.log(chalk.green(` ✓ Found s2 component: ${pair.componentName} (type: ${s2ComponentData?.type})`)); + s2ComponentData = JSON.parse(JSON.stringify(s2ComponentData)); + s2ComponentData.name = pair.componentName; + branchApi.exports[pair.componentName] = s2ComponentData; + } else { + console.log(chalk.red(` ✗ s2 component ${pair.componentName} not found - skipping comparison`)); + // Return empty diffs to skip this comparison + return {diffs: [], name, skipped: true, reason: 's2 component not found'}; + } + + allExportNames = [pair.componentName]; } else { - name = pair.pubApi.replace(/.*published-api/, ''); - } + // Original branch vs published comparison + if (pair.branchApi) { + name = pair.branchApi.replace(/.*branch-api/, ''); + } else { + name = pair.pubApi.replace(/.*published-api/, ''); + } - if (args.values.package && !args.values.package.includes(name)) { - return {diff: {}, name}; - } - if (args.values.verbose) { - console.log(`diffing ${name}`); + if (args.values.package && !args.values.package.includes(name)) { + return {diffs: [], name}; + } + if (args.values.verbose) { + console.log(`diffing ${name}`); + } + publishedApi = pair.pubApi === null ? {exports: {}} : getAPI(pair.pubApi); + branchApi = pair.branchApi === null ? {exports: {}} : getAPI(pair.branchApi); + allExportNames = [...new Set([...Object.keys(publishedApi.exports), ...Object.keys(branchApi.exports)])]; } - let publishedApi = pair.pubApi === null ? {exports: {}} : getAPI(pair.pubApi); - let branchApi = pair.branchApi === null ? {exports: {}} : getAPI(pair.branchApi); + let publishedInterfaces = rebuildInterfaces(publishedApi); let branchInterfaces = rebuildInterfaces(branchApi); - let allExportNames = [...new Set([...Object.keys(publishedApi.exports), ...Object.keys(branchApi.exports)])]; let allInterfaces = [...new Set([...Object.keys(publishedInterfaces), ...Object.keys(branchInterfaces)])]; let formattedPublishedInterfaces = ''; let formattedBranchInterfaces = ''; @@ -281,7 +594,7 @@ function getDiff(pair) { if (args.values.interface && args.values.interface !== iname) { return; } - let simplifiedName = allExportNames[index]; + let simplifiedName = allExportNames[index] || iname; let codeDiff = Diff.structuredPatch(iname, iname, formattedPublishedInterfaces[index], formattedBranchInterfaces[index], {newlineIsToken: true}); if (args.values.verbose) { console.log(util.inspect(codeDiff, {depth: null})); @@ -291,7 +604,7 @@ function getDiff(pair) { let lines = formattedPublishedInterfaces[index].split('\n'); codeDiff.hunks.forEach(hunk => { if (hunk.oldStart > prevEnd) { - result = [...result, ...lines.slice(prevEnd - 1, hunk.oldStart - 1).map((item, index) => ` ${item}`)]; + result = [...result, ...lines.slice(prevEnd - 1, hunk.oldStart - 1).map(item => ` ${item}`)]; } if (args.values.isCI) { result = [...result, ...hunk.lines]; @@ -309,14 +622,18 @@ function getDiff(pair) { }); let joinedResult = ''; if (codeDiff.hunks.length > 0) { - joinedResult = [...result, ...lines.slice(prevEnd).map((item, index) => ` ${item}`)].join('\n'); + joinedResult = [...result, ...lines.slice(prevEnd).map(item => ` ${item}`)].join('\n'); } if (args.values.isCI && result.length > 0) { joinedResult = `\`\`\`diff ${joinedResult} \`\`\``; } - diffs.push({iname, result: joinedResult, simplifiedName: `${name.replace('/dist/api.json', '')}:${simplifiedName}`}); + + let diffSimplifiedName = args.values['compare-v3-s2'] + ? `${pair.componentName}` + : `${name.replace('/dist/api.json', '')}:${simplifiedName}`; + diffs.push({iname, result: joinedResult, simplifiedName: diffSimplifiedName}); }); return {diffs, name}; @@ -372,10 +689,36 @@ function processType(value) { return 'boolean'; } if (value.type === 'union') { - return value.elements.map(processType).join(' | '); + // Sort union elements alphabetically for consistent output, with functions last + let elements = value.elements.map(processType); + elements.sort((a, b) => { + let aIsFunction = a.startsWith('(') && a.includes('=>'); + let bIsFunction = b.startsWith('(') && b.includes('=>'); + if (aIsFunction && !bIsFunction) { + return 1; // a comes after b + } + if (!aIsFunction && bIsFunction) { + return -1; // a comes before b + } + return a.localeCompare(b); // alphabetical if both are or aren't functions + }); + return elements.join(' | '); } if (value.type === 'intersection') { - return `(${value.types.map(processType).join(' & ')})`; + // Sort intersection types alphabetically for consistent output, with functions last + let types = value.types.map(processType); + types.sort((a, b) => { + let aIsFunction = a.startsWith('(') && a.includes('=>'); + let bIsFunction = b.startsWith('(') && b.includes('=>'); + if (aIsFunction && !bIsFunction) { + return 1; // a comes after b + } + if (!aIsFunction && bIsFunction) { + return -1; // a comes before b + } + return a.localeCompare(b); // alphabetical if both are or aren't functions + }); + return `(${types.join(' & ')})`; } if (value.type === 'application') { let name = value.base.name; @@ -474,6 +817,77 @@ ${value.exact ? '\\}' : '}'}`; return `UNKNOWN: ${value.type}`; } +// Helper to find a type by name in links or exports +function findTypeByName(name, json) { + if (json.links) { + for (let linkId in json.links) { + if (json.links[linkId].name === name) { + return json.links[linkId]; + } + } + } + if (json.exports && json.exports[name]) { + return json.exports[name]; + } + return null; +} + +// Helper function to resolve a props type by following links, identifiers, and applications +function resolvePropsType(propsType, json) { + let maxDepth = 10; + let depth = 0; + + while (depth < maxDepth) { + depth++; + + // If it's a link, resolve it + if (propsType.type === 'link' && propsType.id && json.links && json.links[propsType.id]) { + propsType = json.links[propsType.id]; + continue; + } + + // If it's an application (like ItemProps), try to get the base type + if (propsType.type === 'application' && propsType.base) { + if (propsType.base.type === 'link' && propsType.base.id && json.links && json.links[propsType.base.id]) { + propsType = json.links[propsType.base.id]; + continue; + } + if (propsType.base.type === 'identifier' && propsType.base.name) { + let resolved = findTypeByName(propsType.base.name, json); + if (resolved) { + propsType = resolved; + continue; + } + } + } + + // If the resolved type is a component, extract props from it + if (propsType.type === 'component' && propsType.props) { + propsType = propsType.props; + continue; + } + + // If it's an identifier, try to resolve it + if (propsType.type === 'identifier' && propsType.name) { + let resolved = findTypeByName(propsType.name, json); + if (resolved) { + propsType = resolved; + continue; + } + } + + // If we have properties, we're done + if ((propsType.type === 'object' || propsType.type === 'interface') && propsType.properties) { + break; + } + + // Can't resolve further + break; + } + + return propsType; +} + function rebuildInterfaces(json) { let exports = {}; if (!json.exports) { @@ -503,6 +917,13 @@ function rebuildInterfaces(json) { if (prop.access === 'private') { return; } + // Skip style props from baseStyleProps and viewStyleProps + if (stylePropsToExclude.has(prop.name)) { + if (args.values.verbose) { + console.log(chalk.gray(` [FILTERED] Skipping style prop: ${prop.name} from ${key}`)); + } + return; + } let name = prop.name; if (item.name === null) { name = key; @@ -533,22 +954,93 @@ function rebuildInterfaces(json) { exports[name] = compInterface; } else if (item.type === 'function') { let funcInterface = {}; - Object.entries(item.parameters).sort((([keyA], [keyB]) => keyA > keyB ? 1 : -1)).forEach(([, param]) => { - if (param.access === 'private') { - return; + + // Check if this is a React component function with a props parameter + // In that case, extract the props from the parameter type instead of treating it as a function + let isReactComponent = false; + let propsParam = null; + + if (item.parameters && item.parameters.length > 0) { + let firstParam = item.parameters[0]; + // Check if it's a props parameter (has a value that's a link/identifier/object/application) + if (firstParam && firstParam.value && + (firstParam.value.type === 'link' || + firstParam.value.type === 'identifier' || + firstParam.value.type === 'object' || + firstParam.value.type === 'application')) { + isReactComponent = true; + propsParam = firstParam; } - let name = param.name; - let optional = param.optional; - let defaultVal = param.default; - let value = processType(param.value); - funcInterface[name] = {optional, defaultVal, value}; - }); - let returnVal = processType(item.return); - let name = item.name ?? key; - funcInterface['returnVal'] = returnVal; - if (item.typeParameters?.length > 0) { - funcInterface['typeParameters'] = `<${item.typeParameters.map(processType).sort().join(', ')}>`; } + + if (isReactComponent && propsParam) { + // Extract props from the parameter type + let propsType = resolvePropsType(propsParam.value, json); + + // If it's an object or interface type, extract properties + if (propsType.type === 'object' && propsType.properties) { + Object.entries(propsType.properties).sort((([keyA], [keyB]) => keyA > keyB ? 1 : -1)).forEach(([, prop]) => { + if (prop.access === 'private') { + return; + } + // Skip style props from baseStyleProps and viewStyleProps + if (stylePropsToExclude.has(prop.name)) { + return; + } + let name = prop.name; + let optional = prop.optional; + let defaultVal = prop.default; + let value = processType(prop.value); + funcInterface[name] = {optional, defaultVal, value}; + }); + } else if (propsType.type === 'interface' && propsType.properties) { + Object.entries(propsType.properties).sort((([keyA], [keyB]) => keyA > keyB ? 1 : -1)).forEach(([, prop]) => { + if (prop.access === 'private' || prop.access === 'protected') { + return; + } + // Skip style props from baseStyleProps and viewStyleProps + if (stylePropsToExclude.has(prop.name)) { + return; + } + let name = prop.name; + let optional = prop.optional; + let defaultVal = prop.default; + let value = processType(prop.value); + funcInterface[name] = {optional, defaultVal, value}; + }); + } else { + // Fallback: treat as regular function parameter + let name = propsParam.name; + let optional = propsParam.optional; + let defaultVal = propsParam.default; + let value = processType(propsParam.value); + funcInterface[name] = {optional, defaultVal, value}; + } + + // Add type parameters if present + if (item.typeParameters?.length > 0) { + funcInterface['typeParameters'] = `<${item.typeParameters.map(processType).sort().join(', ')}>`; + } + } else { + // Regular function: process all parameters + Object.entries(item.parameters).sort((([keyA], [keyB]) => keyA > keyB ? 1 : -1)).forEach(([, param]) => { + if (param.access === 'private') { + return; + } + let name = param.name; + let optional = param.optional; + let defaultVal = param.default; + let value = processType(param.value); + funcInterface[name] = {optional, defaultVal, value}; + }); + let returnVal = processType(item.return); + funcInterface['returnVal'] = returnVal; + if (item.typeParameters?.length > 0) { + funcInterface['typeParameters'] = `<${item.typeParameters.map(processType).sort().join(', ')}>`; + } + } + + let name = item.name ?? key; exports[name] = funcInterface; } else if (item.type === 'interface') { let funcInterface = {}; @@ -556,6 +1048,13 @@ function rebuildInterfaces(json) { if (property.access === 'private' || property.access === 'protected') { return; } + // Skip style props from baseStyleProps and viewStyleProps + if (stylePropsToExclude.has(property.name)) { + if (args.values.verbose) { + console.log(chalk.gray(` [FILTERED] Skipping style prop: ${property.name} from ${key}`)); + } + return; + } let name = property.name; let optional = property.optional; let defaultVal = property.default; @@ -600,7 +1099,13 @@ function rebuildInterfaces(json) { } function formatProp([name, prop]) { - return ` ${name}${prop.optional ? '?' : ''}: ${prop.value}${prop.defaultVal != null ? ` = ${prop.defaultVal}` : ''}`; + let defaultValue = ''; + if (prop.defaultVal != null) { + // Normalize default values to use single quotes instead of double quotes + let normalizedDefault = String(prop.defaultVal).replace(/"/g, "'"); + defaultValue = ` = ${normalizedDefault}`; + } + return ` ${name}${prop.optional ? '?' : ''}: ${prop.value}${defaultValue}`; } function formatInterfaces(interfaces, allInterfaces) { diff --git a/yarn.lock b/yarn.lock index 6730fecdde7..466d2b68259 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7540,6 +7540,7 @@ __metadata: "@react-stately/utils": "npm:^3.10.8" "@react-types/dialog": "npm:^3.5.22" "@react-types/grid": "npm:^3.3.6" + "@react-types/overlays": "npm:^3.9.2" "@react-types/provider": "npm:^3.8.13" "@react-types/shared": "npm:^3.32.1" "@react-types/table": "npm:^3.13.4"