Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(pickers) docs & styles for non-layered dropdowns for mobile a11y #18495

Merged
merged 10 commits into from
Jun 10, 2021
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "fix zIndex style for Callouts with doNotLayer=true",
"packageName": "@fluentui/react",
"email": "sarah.higley@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Add picker example of inline suggestions dropdown",
"packageName": "@fluentui/react-examples",
"email": "sarah.higley@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,11 @@

- Use the people picker to add someone to the To line of an email, or to add someone to a list.
- Use the `MemberList PeoplePicker` to display selections below the input field.

### Accessibility

PeoplePicker dropdowns render in their own layer by default to ensure they are not clipped by containers with `overflow: hidden` or `overflow: scroll`. This causes extra difficulty for people who use touch-based screen readers, so we recommend rendering the PeoplePicker inline unless it is in an overflow container. To do so, set the following property on the PeoplePicker:

```js
pickerCalloutProps={{ doNotLayer: true }}
```
Comment on lines +8 to +12
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Super nitpicky/opinion-based but I think it looks a bit funny to have the prop on its own line (up to you though).
image

Suggested change
PeoplePicker dropdowns render in their own layer by default to ensure they are not clipped by containers with `overflow: hidden` or `overflow: scroll`. This causes extra difficulty for people who use touch-based screen readers, so we recommend rendering the PeoplePicker inline unless it is in an overflow container. To do so, set the following property on the PeoplePicker:
```js
pickerCalloutProps={{ doNotLayer: true }}
```
PeoplePicker dropdowns render in their own layer by default to ensure they are not clipped by containers with `overflow: hidden` or `overflow: scroll`. This causes extra difficulty for people who use touch-based screen readers, so we recommend rendering the PeoplePicker inline unless it is in an overflow container. To render inline, set `pickerCalloutProps={{ doNotLayer: true }}` on the PeoplePicker.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only comment I didn't change -- I'm leaning towards keeping it on a new line, just because I'm really hoping people will actually use it, so I'd like to make it visually called out & easy to copy/paste. I'm not super tied to it though -- in vNext, I think I'd actually like it to be inline by default, with a prop to render it in a new layer 😄

7 changes: 7 additions & 0 deletions packages/react-examples/src/react/Pickers/Pickers.doc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { PickerCustomResultExample } from './Picker.CustomResult.Example';

import { IDocPageProps } from '@fluentui/react/lib/common/DocPage.types';
import { TagPickerBasicExample } from './TagPicker.Basic.Example';
import { TagPickerInlineExample } from './TagPicker.Inline.Example';

const TagPickerExampleCode = require('!raw-loader?esModule=false!@fluentui/react-examples/src/react/Pickers/TagPicker.Basic.Example.tsx') as string;
const TagPickerInlineExampleCode = require('!raw-loader?esModule=false!@fluentui/react-examples/src/react/Pickers/TagPicker.Inline.Example.tsx') as string;
const PickerCustomResultExampleCode = require('!raw-loader?esModule=false!@fluentui/react-examples/src/react/Pickers/Picker.CustomResult.Example.tsx') as string;

export const PickersPageProps: IDocPageProps = {
Expand All @@ -17,6 +19,11 @@ export const PickersPageProps: IDocPageProps = {
code: TagPickerExampleCode,
view: <TagPickerBasicExample />,
},
{
title: 'Tag Picker with inline suggestions',
code: TagPickerInlineExampleCode,
view: <TagPickerInlineExample />,
},
{
title: 'Custom Picker (Document Picker)',
code: PickerCustomResultExampleCode,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import * as React from 'react';

import { TagPicker, ITag, IBasePickerSuggestionsProps } from '@fluentui/react/lib/Pickers';
import { mergeStyles } from '@fluentui/react/lib/Styling';
import { useId } from '@fluentui/react-hooks';

const rootClass = mergeStyles({
maxWidth: 500,
});

const pickerSuggestionsProps: IBasePickerSuggestionsProps = {
suggestionsHeaderText: 'Suggested colors',
noResultsFoundText: 'No color tags found',
};

const testTags: ITag[] = [
'black',
'blue',
'brown',
'cyan',
'green',
'magenta',
'mauve',
'orange',
'pink',
'purple',
'red',
'rose',
'violet',
'white',
'yellow',
].map(item => ({ key: item, name: item[0].toUpperCase() + item.slice(1) }));

const listContainsTagList = (tag: ITag, tagList?: ITag[]) => {
if (!tagList || !tagList.length || tagList.length === 0) {
return false;
}
return tagList.some(compareTag => compareTag.key === tag.key);
};

const filterSuggestedTags = (filterText: string, tagList: ITag[]): ITag[] => {
return filterText
? testTags.filter(
tag => tag.name.toLowerCase().indexOf(filterText.toLowerCase()) === 0 && !listContainsTagList(tag, tagList),
)
: [];
};

const getTextFromItem = (item: ITag) => item.name;

export const TagPickerInlineExample: React.FunctionComponent = () => {
const pickerId = useId('inline-picker');

return (
<div className={rootClass}>
<label htmlFor={pickerId}>Choose a color</label>
<TagPicker
removeButtonAriaLabel="Remove"
selectionAriaLabel="Selected colors"
onResolveSuggestions={filterSuggestedTags}
getTextFromItem={getTextFromItem}
pickerSuggestionsProps={pickerSuggestionsProps}
itemLimit={4}
// this option tells the picker's callout to render inline instead of in a new layer
pickerCalloutProps={{ doNotLayer: true }}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To make it more explicit, can you add a comment calling out that this is the important part?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done! Let me know if you like the wording.

inputProps={{
id: pickerId,
}}
/>
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,11 @@

- Use a picker to quickly search for a few tags or files.
- Use a picker to manage a group of tags or files.

### Accessibility

Picker dropdowns render in their own layer by default to ensure they are not clipped by containers with `overflow: hidden` or `overflow: scroll`. This causes extra difficulty for people who use touch-based screen readers, so we recommend rendering pickers inline unless they are in overflow containers. To do so, set the following property on the picker, as demonstrated in the Tag Picker with Inline Suggestions example:

```js
pickerCalloutProps={{ doNotLayer: true }}
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Component Examples renders TagPicker.Inline.Example.tsx correctly 1`] = `
<div
className=

{
max-width: 500px;
}
>
<label
htmlFor="inline-picker0"
>
Choose a color
</label>
<div
className="ms-BasePicker"
onBlur={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
>
<span
hidden={true}
id="selected-items-id__1-label"
>
Selected colors
</span>
<div
className="ms-SelectionZone"
onClick={[Function]}
onContextMenu={[Function]}
onDoubleClick={[Function]}
onFocusCapture={[Function]}
onKeyDown={[Function]}
onKeyDownCapture={[Function]}
onMouseDown={[Function]}
onMouseDownCapture={[Function]}
role="presentation"
>
<div
className=
ms-BasePicker-text
{
align-items: center;
border-radius: 2px;
border: 1px solid #605e5c;
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
min-height: 30px;
min-width: 180px;
position: relative;
}
&:hover {
border-color: #323130;
}
>
<input
aria-autocomplete="both"
aria-controls=""
aria-expanded={false}
aria-haspopup="listbox"
autoCapitalize="off"
autoComplete="off"
className=
ms-BasePicker-input
{
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
align-self: flex-end;
background-color: transparent;
border-radius: 2px;
border: none;
color: #323130;
flex-grow: 1;
font-family: 'Segoe UI', 'Segoe UI Web (West European)', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Roboto', 'Helvetica Neue', sans-serif;
font-size: 14px;
font-weight: 400;
height: 30px;
outline: none;
padding-bottom: 0;
padding-left: 6px;
padding-right: 6px;
padding-top: 0;
}
&::-ms-clear {
display: none;
}
data-lpignore={true}
id="inline-picker0"
onBlur={[Function]}
onChange={[Function]}
onClick={[Function]}
onCompositionEnd={[Function]}
onCompositionStart={[Function]}
onCompositionUpdate={[Function]}
onFocus={[Function]}
onInput={[Function]}
onKeyDown={[Function]}
role="combobox"
spellCheck={false}
style={
Object {
"fontFamily": "inherit",
}
}
value=""
/>
</div>
</div>
</div>
</div>
`;
1 change: 1 addition & 0 deletions packages/react/etc/react.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2311,6 +2311,7 @@ export interface ICalloutContentStyleProps {
calloutMinWidth?: number;
calloutWidth?: number;
className?: string;
doNotLayer?: boolean;
overflowYHidden?: boolean;
positions?: ICalloutPositionedInfo;
theme: ITheme;
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/components/Callout/Callout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Layer } from '../../Layer';

export const Callout: React.FunctionComponent<ICalloutProps> = React.forwardRef<HTMLDivElement, ICalloutProps>(
({ layerProps, doNotLayer, ...rest }, forwardedRef) => {
const content = <CalloutContent {...rest} ref={forwardedRef} />;
const content = <CalloutContent {...rest} doNotLayer={doNotLayer} ref={forwardedRef} />;
return doNotLayer ? content : <Layer {...layerProps}>{content}</Layer>;
},
);
Expand Down
5 changes: 5 additions & 0 deletions packages/react/src/components/Callout/Callout.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,11 @@ export interface ICalloutContentStyleProps {
* Min width for callout including borders.
*/
calloutMinWidth?: number;

/**
* If true, a z-index should be set on the root element (since the Callout will not be rendered on a new layer).
*/
doNotLayer?: boolean;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,7 @@ export const CalloutContentBase: React.FunctionComponent<ICalloutProps> = React.
calloutWidth,
calloutMaxWidth,
calloutMinWidth,
doNotLayer,
finalHeight,
hideOverflow = !!finalHeight,
backgroundColor,
Expand Down Expand Up @@ -500,6 +501,7 @@ export const CalloutContentBase: React.FunctionComponent<ICalloutProps> = React.
backgroundColor,
calloutMaxWidth,
calloutMinWidth,
doNotLayer,
});

const overflowStyle: React.CSSProperties = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { HighContrastSelector, IRawStyle, focusClear, getGlobalClassNames } from '../../Styling';
import { HighContrastSelector, IRawStyle, focusClear, getGlobalClassNames, ZIndexes } from '../../Styling';
import { ICalloutContentStyleProps, ICalloutContentStyles } from './Callout.types';

function getBeakStyle(beakWidth?: number): IRawStyle {
Expand Down Expand Up @@ -26,6 +26,7 @@ export const getStyles = (props: ICalloutContentStyleProps): ICalloutContentStyl
backgroundColor,
calloutMaxWidth,
calloutMinWidth,
doNotLayer,
} = props;

const classNames = getGlobalClassNames(GlobalClassNames, theme);
Expand All @@ -44,6 +45,7 @@ export const getStyles = (props: ICalloutContentStyleProps): ICalloutContentStyl
theme.fonts.medium,
{
position: 'absolute',
zIndex: doNotLayer ? ZIndexes.Layer : undefined,
boxSizing: 'border-box',
borderRadius: effects.roundedCorner2,
boxShadow: effects.elevation16,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ exports[`TeachingBubble renders TeachingBubble correctly 1`] = `
outline: transparent;
position: absolute;
width: calc(100% + 1px);
z-index: 1000000;
}
@media screen and (-ms-high-contrast: active), (forced-colors: active){& {
border-color: WindowText;
Expand Down
32 changes: 32 additions & 0 deletions packages/react/src/components/pickers/BasePicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ describe('BasePicker', () => {
afterEach(() => {
ReactDOM.unmountComponentAtNode(root);
document.body.textContent = '';

// reset any jest timers
if ((setTimeout as any).mock) {
jest.runOnlyPendingTimers();
jest.useRealTimers();
}
});

const BasePickerWithType = BasePicker as new (props: IBasePickerProps<ISimple>) => BasePicker<
Expand Down Expand Up @@ -110,6 +116,32 @@ describe('BasePicker', () => {
disabledTests: ['component-has-root-ref', 'component-handles-ref', 'has-top-level-file'],
});

it('renders inline callout', () => {
jest.useFakeTimers();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add cleanup for this in afterEach to avoid potential interference with other tests?

if ((setTimeout as any).mock) {
  jest.useRealTimers();
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done :)

document.body.appendChild(root);
const picker = React.createRef<IBasePicker<ISimple>>();

ReactDOM.render(
<BasePickerWithType
onResolveSuggestions={onResolveSuggestions}
onRenderItem={onRenderItem}
onRenderSuggestionsItem={basicSuggestionRenderer}
componentRef={picker}
pickerCalloutProps={{ doNotLayer: true, id: 'test' }}
/>,
root,
);

const input = document.querySelector('.ms-BasePicker-input') as HTMLInputElement;
input.focus();
input.value = 'b';
ReactTestUtils.Simulate.input(input);
runAllTimers();

const calloutParent = document.getElementById('test')?.closest('.ms-BasePicker');
expect(calloutParent).toBeTruthy();
});

it('can provide custom renderers', () => {
jest.useFakeTimers();
document.body.appendChild(root);
Expand Down