Skip to content

Commit

Permalink
SpinButton updates from accessibility testing (microsoft#22489)
Browse files Browse the repository at this point in the history
* react-spinbutton: a11y updates

Minor styling and story updates based on accessibility testing.

* react-spinbutton: add labels to spinner buttons

Adds default, English, labels to the increment and decrement buttons of
SpinButton. This provides a default description for assistive tech
users.

This commit implements a new prop, `strings` per RFC microsoft#19258. Default
values are provided for English and the strings support a token, `{step}`
that will be replaced with the value of the `step` prop. The `{step}`
token may be omitted. All string keys are required but invididual labels
may be overridden by passing the `aria-label` prop to a slot.

* react-spinbutton: update API snapshot

* react-spinbutton: remove commented out TODOs
  • Loading branch information
spmonahan authored and marwan38 committed Jun 13, 2022
1 parent 24ed6b5 commit dd99d21
Show file tree
Hide file tree
Showing 11 changed files with 147 additions and 5 deletions.
7 changes: 7 additions & 0 deletions packages/react-spinbutton/etc/react-spinbutton.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export type SpinButtonCommons = {
appearance: 'outline' | 'underline' | 'filledDarker' | 'filledLighter';
size: 'small' | 'medium';
inputType: 'all' | 'spinners-only';
strings?: SpinButtonStrings;
};

// @public (undocumented)
Expand Down Expand Up @@ -68,6 +69,12 @@ export type SpinButtonState = ComponentState<SpinButtonSlots> & Partial<SpinButt
atBound: SpinButtonBounds;
};

// @public (undocumented)
export type SpinButtonStrings = {
incrementButtonLabel: string;
decrementButtonLabel: string;
};

// @public
export const useSpinButton_unstable: (props: SpinButtonProps, ref: React_2.Ref<HTMLInputElement>) => SpinButtonState;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { SpinButtonStrings } from './SpinButton.types';

export const spinButtonDefaultStrings: SpinButtonStrings = {
incrementButtonLabel: 'Increment by {step}',
decrementButtonLabel: 'Decrement by {step}',
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event';
import { SpinButton } from './SpinButton';
import { isConformant } from '../../common/isConformant';
import * as Keys from '@fluentui/keyboard-keys';
import { SpinButtonStrings } from './SpinButton.types';

const getSpinButtonInput = (): HTMLInputElement => {
return screen.getByRole('spinbutton') as HTMLInputElement;
Expand All @@ -18,25 +19,33 @@ describe('SpinButton', () => {
});

it('renders a default uncontrolled state', () => {
render(<SpinButton defaultValue={10} />);
const { getAllByRole } = render(<SpinButton defaultValue={10} />);

const spinButton = getSpinButtonInput();
expect(spinButton.value).toEqual('10');
expect(spinButton.getAttribute('aria-valuenow')).toEqual('10');
expect(spinButton.getAttribute('aria-valuetext')).toBeNull();
expect(spinButton.getAttribute('aria-valuemin')).toBeNull();
expect(spinButton.getAttribute('aria-valuemax')).toBeNull();

const [incrementButton, decrementButton] = getAllByRole('button');
expect(incrementButton.getAttribute('aria-label')).toEqual('Increment by 1');
expect(decrementButton.getAttribute('aria-label')).toEqual('Decrement by 1');
});

it('renders a default controlled state', () => {
render(<SpinButton value={1} onChange={jest.fn()} />);
const { getAllByRole } = render(<SpinButton value={1} onChange={jest.fn()} />);

const spinButton = getSpinButtonInput();
expect(spinButton.value).toEqual('1');
expect(spinButton.getAttribute('aria-valuenow')).toEqual('1');
expect(spinButton.getAttribute('aria-valuetext')).toBeNull();
expect(spinButton.getAttribute('aria-valuemin')).toBeNull();
expect(spinButton.getAttribute('aria-valuemax')).toBeNull();

const [incrementButton, decrementButton] = getAllByRole('button');
expect(incrementButton.getAttribute('aria-label')).toEqual('Increment by 1');
expect(decrementButton.getAttribute('aria-label')).toEqual('Decrement by 1');
});

it('does not render `displayValue` when uncontrolled', () => {
Expand Down Expand Up @@ -501,4 +510,32 @@ describe('SpinButton', () => {
userEvent.type(spinButton, '23');
expect(onChange).toHaveBeenCalledTimes(6); // no change should fire
});

it('applies custom strings', () => {
const strings: SpinButtonStrings = {
incrementButtonLabel: `Increment SpinButton by {step}`,
decrementButtonLabel: `Decrement It`,
};

const { getAllByRole } = render(<SpinButton strings={strings} defaultValue={0} />);

const [incrementButton, decrementButton] = getAllByRole('button');
expect(incrementButton.getAttribute('aria-label')).toEqual('Increment SpinButton by 1');
expect(decrementButton.getAttribute('aria-label')).toEqual('Decrement It');
});

it('overrides custom strings with slot props', () => {
const strings: SpinButtonStrings = {
incrementButtonLabel: `Increment SpinButton by {step}`,
decrementButtonLabel: `Decrement It`,
};

const { getAllByRole } = render(
<SpinButton strings={strings} defaultValue={0} incrementButton={{ 'aria-label': 'Increment Override' }} />,
);

const [incrementButton, decrementButton] = getAllByRole('button');
expect(incrementButton.getAttribute('aria-label')).toEqual('Increment Override');
expect(decrementButton.getAttribute('aria-label')).toEqual('Decrement It');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ export type SpinButtonCommons = {
* @default all
*/
inputType: 'all' | 'spinners-only';

/**
* Strings for localizing text in the control.
*/
strings?: SpinButtonStrings;
};

/**
Expand Down Expand Up @@ -161,3 +166,17 @@ export type SpinButtonOnChangeData = {

export type SpinButtonSpinState = 'rest' | 'up' | 'down';
export type SpinButtonBounds = 'none' | 'min' | 'max' | 'both';

export type SpinButtonStrings = {
/**
* Label applied to the increment button.
* Can include the token "\{step\}" which will be replaced with the value of the `step` prop.
*/
incrementButtonLabel: string;

/**
* Label applied to the decrement button.
* Can include the token "\{step\}" which will be replaced with the value of the `step` prop.
*/
decrementButtonLabel: string;
};
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
SpinButtonChangeEvent,
SpinButtonBounds,
} from './SpinButton.types';
import { spinButtonDefaultStrings } from './SpinButton.strings';
import { calculatePrecision, precisionRound, getBound, clampWhenInRange } from '../../utils/index';
import { ChevronUp16Regular, ChevronDown16Regular } from '@fluentui/react-icons';

Expand Down Expand Up @@ -47,7 +48,7 @@ export const useSpinButton_unstable = (props: SpinButtonProps, ref: React.Ref<HT
const nativeProps = getPartitionedNativeProps({
props,
primarySlotTagName: 'input',
excludedPropNames: ['onChange', 'size'],
excludedPropNames: ['onChange', 'size', 'min', 'max'],
});

const {
Expand All @@ -67,6 +68,7 @@ export const useSpinButton_unstable = (props: SpinButtonProps, ref: React.Ref<HT
incrementButton,
decrementButton,
inputType = 'all',
strings = spinButtonDefaultStrings,
} = props;

const precision = React.useMemo(() => {
Expand Down Expand Up @@ -114,6 +116,7 @@ export const useSpinButton_unstable = (props: SpinButtonProps, ref: React.Ref<HT
autoComplete: 'off',
role: 'spinbutton',
appearance: appearance,
type: 'text',
...nativeProps.primary,
},
}),
Expand All @@ -123,6 +126,7 @@ export const useSpinButton_unstable = (props: SpinButtonProps, ref: React.Ref<HT
tabIndex: -1,
children: <ChevronUp16Regular />,
disabled: nativeProps.primary.disabled,
'aria-label': strings.incrementButtonLabel.replace('{step}', step.toString()),
},
}),
decrementButton: resolveShorthand(decrementButton, {
Expand All @@ -131,6 +135,7 @@ export const useSpinButton_unstable = (props: SpinButtonProps, ref: React.Ref<HT
tabIndex: -1,
children: <ChevronDown16Regular />,
disabled: nativeProps.primary.disabled,
'aria-label': strings.decrementButtonLabel.replace('{step}', step.toString()),
},
}),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,20 @@ const useButtonStyles = makeStyles({
':disabled': {
color: tokens.colorNeutralForegroundDisabled,
},
'@media (forced-colors: active)': {
color: 'ButtonText',
':enabled': {
':hover': {
color: 'ButtonText',
},
':active': {
color: 'ButtonText',
},
[`&.${spinButtonExtraClassNames.buttonActive}`]: {
color: 'ButtonText',
},
},
},
},

// These designs are not yet finalized so this is copy-paste for the "outline"
Expand Down Expand Up @@ -324,6 +338,20 @@ const useButtonDisabledStyles = makeStyles({
backgroundColor: 'transparent',
},
},
'@media (forced-colors: active)': {
color: 'GrayText',
':enabled': {
':hover': {
color: 'GrayText',
},
':active': {
color: 'GrayText',
},
[`&.${spinButtonExtraClassNames.buttonActive}`]: {
color: 'GrayText',
},
},
},
},

underline: {
Expand Down
1 change: 1 addition & 0 deletions packages/react-spinbutton/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ export type {
SpinButtonState,
SpinButtonSpinState,
SpinButtonBounds,
SpinButtonStrings,
} from './SpinButton';
2 changes: 2 additions & 0 deletions packages/react-spinbutton/src/stories/SpinButton.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export { Appearance } from './SpinButtonAppearance.stories';
export { RTL } from './SpinButtonRTL.stories';
export { Disabled } from './SpinButtonDisabled.stories';
export { InputType } from './SpinButtonInputType.stories';
export { Strings } from './SpinButtonStrings.stories';

import { makeStyles, mergeClasses, shorthands } from '@griffel/react';

const useDecoratorStyles = makeStyles({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ export const Bounds = () => {

return (
<>
<Label htmlFor={id}>Bounded SpinButton (min: 0, max: 20)</Label>
<Label htmlFor={id}>Bounded SpinButton</Label>
<SpinButton value={spinButtonValue} min={0} max={20} onChange={onSpinButtonChange} id={id} />
<p>min: 0, max: 20</p>
</>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ type ParserFn = (formattedValue: string) => number;

export const DisplayValue = () => {
const formatter: FormatterFn = value => {
return `${value}"`;
return `$${value}`;
};

const parser: ParserFn = formattedValue => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as React from 'react';
import { SpinButton } from '../index';
import { Label } from '@fluentui/react-label';
import { useId } from '@fluentui/react-utilities';
import type { SpinButtonStrings } from '../SpinButton';

export const Strings = () => {
const id = useId();

const strings: SpinButtonStrings = {
// Uses the `{step}` token which will be replaced by the `step` prop.
incrementButtonLabel: 'Increment the SpinButton by {step}',
// Omits the `{step}` token.
decrementButtonLabel: 'Decrement',
};

return (
<>
<Label htmlFor={id}>Custom Strings</Label>
<SpinButton strings={strings} defaultValue={0} id={id} />
<p>
Inspect the <code>aria-label</code> attributes for the increment and decrement buttons in dev tools, or use a
tool like a screen reader to hear the labels announced.
</p>
</>
);
};

Strings.parameters = {
docs: {
description: {
story: `SpinButton increment and decrement button \`aria-label\`s can be customized with the \`strings\` prop.
This feature allows labels to be localized.`,
},
},
};

0 comments on commit dd99d21

Please sign in to comment.