Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: omit the default TagPicker expand icon from headless state",
"packageName": "@fluentui/react-tag-picker",
"email": "vgenaev@gmail.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: separate headless teaching popover title state from bundled icon defaults",
"packageName": "@fluentui/react-teaching-popover",
"email": "vgenaev@gmail.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as TagPicker from '@fluentui/react-headless-components-preview/tag-picker';

console.log(TagPicker);

export default {
name: '@fluentui/react-headless-components-preview/tag-picker',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as TeachingPopover from '@fluentui/react-headless-components-preview/teaching-popover';

console.log(TeachingPopover);

export default {
name: '@fluentui/react-headless-components-preview/teaching-popover',
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { isConformant } from '../../testing/isConformant';
import { TeachingPopover } from './TeachingPopover';
import { TeachingPopoverHeader } from './TeachingPopoverHeader';
import { TeachingPopoverTrigger } from './TeachingPopoverTrigger';
import { TeachingPopoverSurface } from './TeachingPopoverSurface';
import { TeachingPopoverTitle } from './TeachingPopoverTitle';

describe('TeachingPopover', () => {
isConformant({
Expand Down Expand Up @@ -43,6 +45,52 @@ describe('TeachingPopover', () => {
expect(getByText('Surface content')).toBeInTheDocument();
});

it('does not render default header or title icons', () => {
const { container, getByTestId } = render(
<TeachingPopover defaultOpen>
<TeachingPopoverTrigger>
<button>Trigger</button>
</TeachingPopoverTrigger>
<TeachingPopoverSurface>
<TeachingPopoverHeader data-testid="header">Tips</TeachingPopoverHeader>
<TeachingPopoverTitle data-testid="title">Title</TeachingPopoverTitle>
</TeachingPopoverSurface>
</TeachingPopover>,
);
const header = getByTestId('header');
const title = getByTestId('title');

expect(container.querySelector('svg')).toBeNull();
expect(header.querySelector('[aria-hidden="true"]')).toBeEmptyDOMElement();
expect(header.querySelector('button[aria-label="dismiss"]')).toBeEmptyDOMElement();
expect(title.querySelector('button')).toBeNull();
});

it('renders explicitly provided header and title slots', () => {
const { getByTestId } = render(
<TeachingPopover defaultOpen>
<TeachingPopoverTrigger>
<button>Trigger</button>
</TeachingPopoverTrigger>
<TeachingPopoverSurface>
<TeachingPopoverHeader
icon={{ children: <span data-testid="header-icon">Tip</span> }}
dismissButton={{ children: <span data-testid="header-dismiss">Close header</span> }}
>
Tips
</TeachingPopoverHeader>
<TeachingPopoverTitle dismissButton={{ children: <span data-testid="title-dismiss">Close title</span> }}>
Title
</TeachingPopoverTitle>
</TeachingPopoverSurface>
</TeachingPopover>,
);

expect(getByTestId('header-icon')).toHaveTextContent('Tip');
expect(getByTestId('header-dismiss')).toHaveTextContent('Close header');
expect(getByTestId('title-dismiss')).toHaveTextContent('Close title');
});

it('renders an arrow by default (withArrow=true)', () => {
const { getByRole } = render(
<TeachingPopover defaultOpen>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { renderHook } from '@testing-library/react-hooks';
import * as React from 'react';
import { ChevronDownRegular } from '@fluentui/react-icons';

import { TagPickerContextProvider } from '../../contexts/TagPickerContext';
import type { TagPickerContextValue } from '../../contexts/TagPickerContext';
import { useTagPickerControl_unstable } from './useTagPickerControl';
import { useTagPickerControlBase_unstable, useTagPickerControl_unstable } from './useTagPickerControl';

const makeContext = (overrides: Partial<TagPickerContextValue> = {}): TagPickerContextValue => ({
triggerRef: React.createRef<HTMLInputElement>(),
Expand Down Expand Up @@ -35,23 +36,57 @@ const wrap = (overrides: Partial<TagPickerContextValue> = {}): React.FC<{ childr
return Wrapper;
};

describe('useTagPickerControlBase_unstable', () => {
it('does not create an expandIcon or aside by default', () => {
const ref = React.createRef<HTMLDivElement>();
const { result } = renderHook(() => useTagPickerControlBase_unstable({}, ref), { wrapper: wrap() });

expect(result.current.expandIcon).toBeUndefined();
expect(result.current.aside).toBeUndefined();
});

it('creates an expandIcon and aside when explicitly provided', () => {
const ref = React.createRef<HTMLDivElement>();
const { result } = renderHook(() => useTagPickerControlBase_unstable({ expandIcon: {} }, ref), {
wrapper: wrap({ open: true }),
});

expect(result.current.expandIcon).toBeDefined();
expect(result.current.expandIcon?.children).toBeUndefined();
expect(result.current.expandIcon?.['aria-expanded']).toBe(true);
expect(result.current.expandIcon?.role).toBe('button');
expect(result.current.aside).toBeDefined();
});
});

describe('useTagPickerControl_unstable', () => {
it('always renders the internal aside slot', () => {
it('renders the default expandIcon and aside when a popover exists', () => {
const ref = React.createRef<HTMLDivElement>();
const { result } = renderHook(() => useTagPickerControl_unstable({}, ref), { wrapper: wrap() });
const defaultIcon = result.current.expandIcon?.children;

expect(React.isValidElement(defaultIcon) && defaultIcon.type).toBe(ChevronDownRegular);
expect(result.current.aside).toBeDefined();
});

it('renders an expandIcon by default and hides it when noPopover is set', () => {
it('does not render a default expandIcon or aside when noPopover is set', () => {
const ref = React.createRef<HTMLDivElement>();
const withPopover = renderHook(() => useTagPickerControl_unstable({}, ref), { wrapper: wrap() });
const noPopover = renderHook(() => useTagPickerControl_unstable({}, ref), {
wrapper: wrap({ noPopover: true }),
});

expect(withPopover.result.current.expandIcon).toBeDefined();
expect(noPopover.result.current.expandIcon).toBeUndefined();
expect(noPopover.result.current.aside).toBeUndefined();
});

it('preserves a consumer-provided expandIcon', () => {
const ref = React.createRef<HTMLDivElement>();
const customIcon = <span data-testid="custom-icon" />;
const { result } = renderHook(() => useTagPickerControl_unstable({ expandIcon: customIcon }, ref), {
wrapper: wrap(),
});

expect(result.current.expandIcon?.children).toBe(customIcon);
});

it('binds aria-expanded on expandIcon to picker open state', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ import {
useMergedRefs,
} from '@fluentui/react-utilities';
import { useFluent_unstable } from '@fluentui/react-shared-contexts';
import type { TagPickerControlBaseState, TagPickerControlProps, TagPickerControlState } from './TagPickerControl.types';
import { useTagPickerContext_unstable } from '../../contexts/TagPickerContext';
import { useFieldContext_unstable } from '@fluentui/react-field';
import { ChevronDownRegular } from '@fluentui/react-icons';
import { useResizeObserverRef } from '../../utils/useResizeObserverRef';

import type { TagPickerControlBaseState, TagPickerControlProps, TagPickerControlState } from './TagPickerControl.types';
import { tagPickerControlAsideWidthToken } from './useTagPickerControlStyles.styles';
import { useFieldContext_unstable } from '@fluentui/react-field';
import { useTagPickerContext_unstable } from '../../contexts/TagPickerContext';
import { useExpandLabel } from '../../utils/useExpandLabel';
import { useResizeObserverRef } from '../../utils/useResizeObserverRef';

/**
* Create the base state required to render TagPickerControl, without design-only state.
Expand Down Expand Up @@ -57,11 +58,9 @@ export const useTagPickerControlBase_unstable = (
}

const expandIcon = slot.optional(props.expandIcon, {
renderByDefault: !noPopover,
defaultProps: {
'aria-expanded': open,
'aria-disabled': disabled ? 'true' : undefined,
children: <ChevronDownRegular />,
role: 'button',
},
elementType: 'span',
Expand Down Expand Up @@ -164,7 +163,15 @@ export const useTagPickerControl_unstable = (
props: TagPickerControlProps,
ref: React.Ref<HTMLDivElement>,
): TagPickerControlState => {
const baseState = useTagPickerControlBase_unstable(props, ref);
const noPopover = useTagPickerContext_unstable(ctx => ctx.noPopover ?? false);
const baseProps =
!noPopover && props.expandIcon === undefined
? {
...props,
expandIcon: <ChevronDownRegular />,
}
: props;
const baseState = useTagPickerControlBase_unstable(baseProps, ref);
const size = useTagPickerContext_unstable(ctx => ctx.size);
const appearance = useTagPickerContext_unstable(ctx => ctx.appearance);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ export type {
TeachingPopoverTitleState,
} from './TeachingPopoverTitle.types';
export { renderTeachingPopoverTitle_unstable } from './renderTeachingPopoverTitle';
export { useTeachingPopoverTitle_unstable, useTeachingPopoverTitleBase_unstable } from './useTeachingPopoverTitle';
export { useTeachingPopoverTitle_unstable } from './useTeachingPopoverTitle';
export { useTeachingPopoverTitleBase_unstable } from './useTeachingPopoverTitleBase';
export {
teachingPopoverTitleClassNames,
useTeachingPopoverTitleStyles_unstable,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,67 +1,13 @@
'use client';

import * as React from 'react';
import { getIntrinsicElementProps, useEventCallback, slot } from '@fluentui/react-utilities';
import type {
TeachingPopoverTitleBaseProps,
TeachingPopoverTitleBaseState,
TeachingPopoverTitleProps,
TeachingPopoverTitleState,
} from './TeachingPopoverTitle.types';
import { DismissFilled, DismissRegular, bundleIcon } from '@fluentui/react-icons';
import { usePopoverContext_unstable } from '@fluentui/react-popover';
import type { TeachingPopoverTitleProps, TeachingPopoverTitleState } from './TeachingPopoverTitle.types';
import { useTeachingPopoverTitleBase_unstable } from './useTeachingPopoverTitleBase';

const DismissIcon = bundleIcon(DismissFilled, DismissRegular);

/**
* Base hook that builds TeachingPopoverTitle state for behavior and structure only.
* Does not bundle/render the dismiss icon and does not read `appearance` from popover context.
* @param props - TeachingPopoverTitle properties
* @param ref - reference to root HTMLElement of TeachingPopoverTitle
*/
export const useTeachingPopoverTitleBase_unstable = (
props: TeachingPopoverTitleBaseProps,
ref: React.Ref<HTMLDivElement>,
): TeachingPopoverTitleBaseState => {
const { dismissButton } = props;

const setOpen = usePopoverContext_unstable(context => context.setOpen);
const triggerRef = usePopoverContext_unstable(context => context.triggerRef);

const onDismissButtonClick = useEventCallback((ev: React.MouseEvent<HTMLButtonElement>) => {
if (!ev.defaultPrevented) {
setOpen(ev, false);
}

if (triggerRef.current) {
triggerRef.current.focus();
}
});

return {
components: {
root: 'h2',
dismissButton: 'button',
},
root: slot.always(
getIntrinsicElementProps('h2', {
ref,
...props,
}),
{ elementType: 'h2' },
),
dismissButton: slot.optional(dismissButton, {
renderByDefault: false,
defaultProps: {
onClick: onDismissButtonClick,
'aria-label': 'dismiss',
'aria-hidden': true,
},
elementType: 'button',
}),
};
};

/**
* Returns the props and state required to render the component
* @param props - TeachingPopoverTitle properties
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import * as React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { PopoverProvider } from '@fluentui/react-popover';
import type { PopoverContextValue } from '@fluentui/react-popover';
import { useTeachingPopoverTitleBase_unstable } from './useTeachingPopoverTitleBase';

const defaultPopoverContext: PopoverContextValue = {
open: true,
setOpen: () => null,
toggleOpen: () => null,
triggerRef: { current: null },
contentRef: { current: null },
arrowRef: { current: null },
openOnContext: false,
openOnHover: false,
size: 'medium',
inline: false,
};

function makeWrapper(contextValue: Partial<PopoverContextValue> = {}) {
const value = { ...defaultPopoverContext, ...contextValue };
return ({ children }: { children: React.ReactNode }) => React.createElement(PopoverProvider, { value }, children);
}

describe('useTeachingPopoverTitleBase_unstable', () => {
it('returns structural state without styled defaults', () => {
const ref = React.createRef<HTMLHeadingElement>();
const { result } = renderHook(() => useTeachingPopoverTitleBase_unstable({ dismissButton: {} }, ref), {
wrapper: makeWrapper(),
});

// eslint-disable-next-line @typescript-eslint/no-deprecated
expect(result.current.components).toEqual({ root: 'h2', dismissButton: 'button' });
expect(result.current).not.toHaveProperty('appearance');
expect(result.current.dismissButton?.children).toBeUndefined();
});

it('dismisses the popover and restores focus', () => {
const ref = React.createRef<HTMLHeadingElement>();
const setOpen = jest.fn();
const focus = jest.fn();
const triggerRef = { current: { focus } as unknown as HTMLElement };
const { result } = renderHook(() => useTeachingPopoverTitleBase_unstable({ dismissButton: {} }, ref), {
wrapper: makeWrapper({ setOpen, triggerRef }),
});
const event = { defaultPrevented: false } as React.MouseEvent<HTMLButtonElement>;

result.current.dismissButton?.onClick?.(event);

expect(setOpen).toHaveBeenCalledWith(event, false);
expect(focus).toHaveBeenCalledTimes(1);
});
});
Loading
Loading