Skip to content

Commit

Permalink
Carousel: Disabled focus handling and CarouselAnnouncer (#32844)
Browse files Browse the repository at this point in the history
  • Loading branch information
Mitch-At-Work committed Sep 18, 2024
1 parent 603fe59 commit fc04799
Show file tree
Hide file tree
Showing 26 changed files with 390 additions and 59 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: Handle nav disabled behavior and provide index based aria labels for next/prev buttons",
"packageName": "@fluentui/react-carousel-preview",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,30 @@ import { ToggleButtonState } from '@fluentui/react-button';
// @public
export const Carousel: ForwardRefComponent<CarouselProps>;

// @public
export const CarouselAnnouncer: ForwardRefComponent<CarouselAnnouncerProps>;

// @public (undocumented)
export const carouselAnnouncerClassNames: SlotClassNames<CarouselAnnouncerSlots>;

// @public
export type CarouselAnnouncerProps = Omit<ComponentProps<Partial<CarouselAnnouncerSlots>>, 'children'> & {
children: AnnouncerIndexRenderFunction;
};

// @public (undocumented)
export type CarouselAnnouncerSlots = {
root: Slot<'div'>;
};

// @public
export type CarouselAnnouncerState = ComponentState<CarouselAnnouncerSlots> & {
renderAnnouncerChild: AnnouncerIndexRenderFunction;
totalSlides: number;
currentIndex: number;
slideGroupList: number[][];
};

// @public
export const CarouselAutoplayButton: ForwardRefComponent<CarouselAutoplayButtonProps>;

Expand All @@ -37,7 +61,6 @@ export const carouselAutoplayButtonClassNames: SlotClassNames<CarouselAutoplayBu

// @public
export type CarouselAutoplayButtonProps = ToggleButtonProps & ComponentProps<CarouselAutoplayButtonSlots> & {
autoplayAriaLabel?: CarouselAutoplayAriaLabelFunction;
onCheckedChange?: EventHandler<CarouselAutoplayChangeData>;
};

Expand Down Expand Up @@ -102,6 +125,7 @@ export type CarouselContextValue = {
selectPageByIndex: (event: React_2.MouseEvent<HTMLButtonElement | HTMLAnchorElement>, value: number, jump?: boolean) => void;
subscribeForValues: (listener: (data: CarouselUpdateData) => void) => () => void;
enableAutoplay: (autoplay: boolean) => void;
containerRef?: React_2.RefObject<HTMLDivElement>;
};

// @public
Expand Down Expand Up @@ -246,6 +270,9 @@ export type NavButtonRenderFunction = (index: number) => React_2.ReactNode;
// @public
export const renderCarousel_unstable: (state: CarouselState, contextValues: CarouselContextValues) => JSX.Element;

// @public
export const renderCarouselAnnouncer_unstable: (state: CarouselAnnouncerState) => JSX.Element;

// @public
export const renderCarouselAutoplayButton_unstable: (state: CarouselAutoplayButtonState) => JSX.Element;

Expand Down Expand Up @@ -273,6 +300,12 @@ export const renderCarouselSlider_unstable: (state: CarouselSliderState, context
// @public
export function useCarousel_unstable(props: CarouselProps, ref: React_2.Ref<HTMLDivElement>): CarouselState;

// @public
export const useCarouselAnnouncer_unstable: (props: CarouselAnnouncerProps, ref: React_2.Ref<HTMLDivElement>) => CarouselAnnouncerState;

// @public
export const useCarouselAnnouncerStyles_unstable: (state: CarouselAnnouncerState) => CarouselAnnouncerState;

// @public
export const useCarouselAutoplayButton_unstable: (props: CarouselAutoplayButtonProps, ref: React_2.Ref<ARIAButtonElement>) => CarouselAutoplayButtonState;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './components/CarouselAnnouncer/index';
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,15 @@ export function useCarousel_unstable(props: CarouselProps, ref: React.Ref<HTMLDi
return nextPageIndex;
});

const mergedRefs = useMergedRefs(ref, containerRef);

return {
components: {
root: 'div',
},
root: slot.always(
getIntrinsicElementProps('div', {
ref: useMergedRefs(ref, containerRef),
ref: mergedRefs,
role: 'region',
...props,
}),
Expand All @@ -74,6 +76,7 @@ export function useCarousel_unstable(props: CarouselProps, ref: React.Ref<HTMLDi

activeIndex,
circular,
containerRef: mergedRefs,
selectPageByElement,
selectPageByDirection,
selectPageByIndex,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export function useCarouselContextValues_unstable(state: CarouselState): Carouse
subscribeForValues,
enableAutoplay,
circular,
containerRef,
} = state;

const carousel = React.useMemo(
Expand All @@ -23,6 +24,7 @@ export function useCarouselContextValues_unstable(state: CarouselState): Carouse
subscribeForValues,
enableAutoplay,
circular,
containerRef,
}),
[
activeIndex,
Expand All @@ -32,6 +34,7 @@ export function useCarouselContextValues_unstable(state: CarouselState): Carouse
subscribeForValues,
enableAutoplay,
circular,
containerRef,
],
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as React from 'react';
import { render } from '@testing-library/react';
import { isConformant } from '../../testing/isConformant';
import { CarouselAnnouncer } from './CarouselAnnouncer';

const renderFunc = (index: number, totalSlides: number, groupList: number[][]) => `Slide ${index} of ${totalSlides}`;

describe('CarouselAnnouncer', () => {
isConformant({
Component: CarouselAnnouncer,
displayName: 'CarouselAnnouncer',
requiredProps: {
children: renderFunc,
},
});

// TODO add more tests here, and create visual regression tests in /apps/vr-tests

it('renders a default state', () => {
const result = render(<CarouselAnnouncer>{renderFunc}</CarouselAnnouncer>);
expect(result.container).toMatchSnapshot();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import * as React from 'react';
import type { ForwardRefComponent } from '@fluentui/react-utilities';
import { useCarouselAnnouncer_unstable } from './useCarouselAnnouncer';
import { renderCarouselAnnouncer_unstable } from './renderCarouselAnnouncer';
import { useCarouselAnnouncerStyles_unstable } from './useCarouselAnnouncerStyles.styles';
import type { CarouselAnnouncerProps } from './CarouselAnnouncer.types';

/**
* CarouselAnnouncer component - This component will enable context for announcements of carousel page changes.
*
* It is recommended to provide a simple current out of total page index string.
*
* Slide group lists are also provided when multiple cards are present in a single slide.
*/
export const CarouselAnnouncer: ForwardRefComponent<CarouselAnnouncerProps> = React.forwardRef((props, ref) => {
const state = useCarouselAnnouncer_unstable(props, ref);

useCarouselAnnouncerStyles_unstable(state);

/**
* @see https://github.com/microsoft/fluentui/blob/master/docs/react-v9/contributing/rfcs/react-components/convergence/custom-styling.md
*
* TODO: 💡 once package will become stable (PR which will be part of promoting PREVIEW package to STABLE),
* - uncomment this line
* - update types {@link file://./../../../../../../../packages/react-components/react-shared-contexts/library/src/CustomStyleHooksContext/CustomStyleHooksContext.ts#CustomStyleHooksContextValue}
* - verify that custom global style override works for your component
*/
// useCustomStyleHook_unstable('useCarouselAnnouncerStyles_unstable')(state);

return renderCarouselAnnouncer_unstable(state);
});

CarouselAnnouncer.displayName = 'CarouselAnnouncer';
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities';

export type CarouselAnnouncerSlots = {
root: Slot<'div'>;
};

export type AnnouncerIndexRenderFunction = (index: number, totalSlides: number, slideGroupList: number[][]) => string;
/**
* CarouselAnnouncer Props
*/
export type CarouselAnnouncerProps = Omit<ComponentProps<Partial<CarouselAnnouncerSlots>>, 'children'> & {
children: AnnouncerIndexRenderFunction;
};

/**
* State used in rendering CarouselAnnouncer
*/
export type CarouselAnnouncerState = ComponentState<CarouselAnnouncerSlots> & {
/**
* The function that will render nav items based on total slides and their index.
*/
renderAnnouncerChild: AnnouncerIndexRenderFunction;

/**
* The total number of slides passed in from carousel context.
*/
totalSlides: number;

/**
* The current index passed in from carousel context.
*/
currentIndex: number;

/**
* The list of cards in each slide based on index.
*/
slideGroupList: number[][];
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`CarouselAnnouncer renders a default state 1`] = `
<div>
<div
aria-live="polite"
class="fui-CarouselAnnouncer"
>
Slide 0 of 0
</div>
</div>
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './CarouselAnnouncer';
export * from './CarouselAnnouncer.types';
export * from './renderCarouselAnnouncer';
export * from './useCarouselAnnouncer';
export * from './useCarouselAnnouncerStyles.styles';
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/** @jsxRuntime automatic */
/** @jsxImportSource @fluentui/react-jsx-runtime */

import { assertSlots } from '@fluentui/react-utilities';
import type { CarouselAnnouncerState, CarouselAnnouncerSlots } from './CarouselAnnouncer.types';

/**
* Render the final JSX of CarouselAnnouncer
*/
export const renderCarouselAnnouncer_unstable = (state: CarouselAnnouncerState) => {
const { renderAnnouncerChild, currentIndex, totalSlides, slideGroupList } = state;
assertSlots<CarouselAnnouncerSlots>(state);

return <state.root>{renderAnnouncerChild(currentIndex, totalSlides, slideGroupList)}</state.root>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import * as React from 'react';
import { getIntrinsicElementProps, slot, useIsomorphicLayoutEffect } from '@fluentui/react-utilities';
import type { CarouselAnnouncerProps, CarouselAnnouncerState } from './CarouselAnnouncer.types';
import { useCarouselContext_unstable as useCarouselContext } from '../CarouselContext';

/**
* Create the state required to render CarouselAnnouncer.
*
* The returned state can be modified with hooks such as useCarouselAnnouncerStyles_unstable,
* before being passed to renderCarouselAnnouncer_unstable.
*
* @param props - props from this instance of CarouselAnnouncer
* @param ref - reference to root HTMLDivElement of CarouselAnnouncer
*/
export const useCarouselAnnouncer_unstable = (
props: CarouselAnnouncerProps,
ref: React.Ref<HTMLDivElement>,
): CarouselAnnouncerState => {
const [totalSlides, setTotalSlides] = React.useState(0);
const [slideGroupList, setSlideGroupList] = React.useState([[0]]);
const subscribeForValues = useCarouselContext(ctx => ctx.subscribeForValues);
const currentIndex = useCarouselContext(ctx => ctx.activeIndex);

useIsomorphicLayoutEffect(() => {
return subscribeForValues(data => {
setTotalSlides(data.navItemsCount);
setSlideGroupList(data.groupIndexList);
});
}, [subscribeForValues]);

return {
totalSlides,
currentIndex,
slideGroupList,
renderAnnouncerChild: props.children,
// TODO add appropriate props/defaults
components: {
// TODO add each slot's element type or component
root: 'div',
},
// TODO add appropriate slots, for example:
// mySlot: resolveShorthand(props.mySlot),
root: slot.always(
getIntrinsicElementProps('div', {
ref,
...props,
children: null,
}),
{
elementType: 'div',
defaultProps: {
'aria-live': 'polite',
},
},
),
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { makeStyles, mergeClasses } from '@griffel/react';
import type { SlotClassNames } from '@fluentui/react-utilities';
import type { CarouselAnnouncerSlots, CarouselAnnouncerState } from './CarouselAnnouncer.types';

export const carouselAnnouncerClassNames: SlotClassNames<CarouselAnnouncerSlots> = {
root: 'fui-CarouselAnnouncer',
};

/**
* Styles for the root slot
* Hidden according to A11Y compatibility: https://www.a11yproject.com/posts/how-to-hide-content/
*/
const useStyles = makeStyles({
root: {
clip: 'rect(0 0 0 0)',
height: '0px',
overflow: 'hidden',
position: 'absolute',
width: '0px',
clipPath: 'inset(50%)',
whiteSpace: 'nowrap',
},
});

/**
* Apply styling to the CarouselAnnouncer slots based on the state
*/
export const useCarouselAnnouncerStyles_unstable = (state: CarouselAnnouncerState): CarouselAnnouncerState => {
'use no memo';

const styles = useStyles();
state.root.className = mergeClasses(carouselAnnouncerClassNames.root, styles.root, state.root.className);

return state;
};
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,6 @@ export type CarouselAutoplayAriaLabelFunction = (autoplay: boolean) => string;
*/
export type CarouselAutoplayButtonProps = ToggleButtonProps &
ComponentProps<CarouselAutoplayButtonSlots> & {
/**
* Override aria label property to provide state
*/
autoplayAriaLabel?: CarouselAutoplayAriaLabelFunction;

/**
* Callback that informs the user when internal autoplay value has changed
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ export const useCarouselAutoplayButton_unstable = (
renderByDefault: true,
elementType: 'span',
}),
'aria-label': props.autoplayAriaLabel?.(autoplay),
...props,
checked: autoplay,
onClick: useEventCallback(mergeCallbacks(handleClick, props.onClick)),
Expand Down
Loading

0 comments on commit fc04799

Please sign in to comment.