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
2 changes: 2 additions & 0 deletions UNRELEASED.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f

### Enhancements

- Added `pressed` state to `Button` ([#2148](https://github.com/Shopify/polaris-react/pull/2148))

### Bug fixes

### Documentation
Expand Down
131 changes: 130 additions & 1 deletion src/components/Button/Button.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,61 @@ $slim-vertical-padding: ($slim-min-height - line-height(body) - rem(2px)) / 2;
$large-min-height: rem(44px);
$large-vertical-padding: ($large-min-height - line-height(body) - rem(2px)) / 2;
$spinner-size: rem(20px);
// darken(color(sky, dark), 4%)
$pressed-border-color: rgb(184, 195, 205);
// darken(color(sky), 2%)
$pressed-hover-background: rgb(217, 222, 228);
// darken(color(sky), 4%)
$pressed-active-background: rgb(211, 217, 223);
// darken(color('indigo'), 10%)
$primary-pressed-lightest: rgb(63, 78, 174);
// darken(color('indigo'), 12%)
$primary-pressed-light: rgb(60, 75, 167);
// darken(color('indigo', 'dark'), 15%)
$primary-pressed-dark: rgb(16, 23, 60);
// darken(color('indigo', 'dark'), 20%)
$primary-pressed-darkest: rgb(11, 15, 39);
// darken(color('red'), 10%)
$destructive-pressed-lightest: rgb(176, 43, 19);
// darken(color('red'), 12%)
$destructive-pressed-light: rgb(167, 41, 18);
// darken(color('red', 'dark'), 15%)
$destructive-pressed-dark: rgb(117, 4, 10);
// darken(color('red', 'dark'), 20%)
$destructive-pressed-darkest: rgb(93, 3, 8);
$partial-button-filled-pressed-box-shadow: inset 0 0 0 0 transparent,
inset 0 1px 1px 0 rgba(22, 29, 37, 0.05), inset 0 0 3px 0;

@mixin pressed-box-shadow($color: transparent) {
box-shadow: 0 0 0 1px $color,
inset 0 1px 1px 0 rgba(color('ink', 'lighter'), 0.1),
inset 0 1px 4px 0 rgba(color('ink', 'lighter'), 0.1);
}

@mixin button-filled-pressed(
$color-lightest,
$color-light,
$color-dark,
$color-darkest
) {
background: linear-gradient(to bottom, $color-lightest, $color-lightest);
border-color: $color-dark;
box-shadow: $partial-button-filled-pressed-box-shadow $color-dark;

&:focus,
&:hover {
transition-duration: duration(fast);
background: linear-gradient(to bottom, $color-light, $color-light);
border-color: $color-dark;
box-shadow: $partial-button-filled-pressed-box-shadow$color-dark;
}

&:active {
background: linear-gradient(to bottom, $color-lightest, $color-lightest);
border-color: $color-dark;
box-shadow: $partial-button-filled-pressed-box-shadow$color-darkest;
}
}

.Button {
@include button-base;
Expand Down Expand Up @@ -82,6 +137,15 @@ $spinner-size: rem(20px);
--p-button-color-disabled: var(--p-branded-action-disabled);
@include button-filled-disabled(color('indigo'));
}

&.pressed {
@include button-filled-pressed(
$primary-pressed-lightest,
$primary-pressed-light,
$primary-pressed-dark,
$primary-pressed-darkest
);
}
}

.destructive {
Expand All @@ -94,6 +158,15 @@ $spinner-size: rem(20px);
&.disabled {
@include button-filled-disabled(color('red'));
}

&.pressed {
@include button-filled-pressed(
$destructive-pressed-lightest,
$destructive-pressed-light,
$destructive-pressed-dark,
$destructive-pressed-darkest
);
}
}

.outline {
Expand All @@ -107,6 +180,14 @@ $spinner-size: rem(20px);
.destructive.outline {
@include button-outline(color('red'));
@include recolor-icon(color('red', 'dark'));

&.pressed {
@include button-outline(
color('red', 'dark'),
rgba(color('red', 'dark'), 0.03)
);
@include recolor-icon(color('red', 'darker'));
}
}

.disabled {
Expand All @@ -131,6 +212,35 @@ $spinner-size: rem(20px);
}
}

.pressed {
background: color(sky);
border-color: $pressed-border-color;
@include pressed-box-shadow;

&:hover {
transition-duration: duration(fast);
background: $pressed-hover-background;
border-color: $pressed-border-color;
@include pressed-box-shadow;
}

&:focus {
border-color: color('indigo');
@include pressed-box-shadow(color('indigo'));
}

&:active {
background: $pressed-active-background;
border-color: $pressed-border-color;
@include pressed-box-shadow;
}

@media (-ms-high-contrast: active) {
color: ms-high-contrast-color('button-text');
background: ms-high-contrast-color('button-text-background');
}
}

// The way the designs work, we need to do lots of reaching down to
// target the content in pseudo-selectors, so we need higher specificity
// in this case.
Expand All @@ -146,6 +256,7 @@ $spinner-size: rem(20px);
box-shadow: none;
color: color('blue');

&.pressed,
&:hover,
&:focus,
&:active {
Expand All @@ -157,6 +268,7 @@ $spinner-size: rem(20px);
text-decoration: underline;
}

&.pressed,
&:focus {
@include high-contrast-button-outline(none);
}
Expand All @@ -165,6 +277,12 @@ $spinner-size: rem(20px);
@include high-contrast-button-outline;
}

&.pressed > .Content {
@include plain-button-backdrop(rgba(color('ink', 'lighter'), 0.1));
}

&.pressed:hover:not(.iconOnly) > .Content,
&.pressed:active:not(.iconOnly) > .Content,
&:focus:not(.iconOnly) > .Content {
@include plain-button-backdrop;
}
Expand Down Expand Up @@ -327,13 +445,24 @@ $spinner-size: rem(20px);
z-index: 0;
}

&.pressed {
background: transparent;
border-color: currentColor;
box-shadow: none;

// stylelint-disable-next-line selector-max-class
&::before {
opacity: 0.05;
}
}

&:hover,
&:focus,
&:active {
background-color: transparent;
border-color: currentColor;
&::before {
opacity: 0.05;
opacity: 0.07;
}
}
}
Expand Down
25 changes: 22 additions & 3 deletions src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, {useRef} from 'react';
import {CaretDownMinor} from '@shopify/polaris-icons';
import {classNames, variationName} from '../../utilities/css';
import {handleMouseUpByBlurring} from '../../utilities/focus';
Expand Down Expand Up @@ -39,6 +39,8 @@ export interface ButtonProps {
textAlign?: TextAlign;
/** Gives the button a subtle alternative to the default button styling, appropriate for certain backdrops */
outline?: boolean;
/** Gives the button the appearance of being pressed */
pressed?: boolean;
/** Allows the button to grow to the width of its container */
fullWidth?: boolean;
/** Displays the button with a disclosure icon */
Expand All @@ -61,7 +63,10 @@ export interface ButtonProps {
ariaControls?: string;
/** Tells screen reader the controlled element is expanded */
ariaExpanded?: boolean;
/** Tells screen reader the element is pressed */
/**
* @deprecated As of release 4.7.0, replaced by {@link https://polaris.shopify.com/components/structure/page#props-pressed}
* Tells screen reader the element is pressed
*/
ariaPressed?: boolean;
/** Callback when clicked */
onClick?(): void;
Expand Down Expand Up @@ -108,7 +113,18 @@ export function Button({
size = DEFAULT_SIZE,
textAlign,
fullWidth,
pressed,
}: ButtonProps) {
const hasGivenDeprecationWarning = useRef(false);

if (ariaPressed && !hasGivenDeprecationWarning.current) {
// eslint-disable-next-line no-console
console.warn(
'Deprecation: The ariaPressed prop has been replaced with pressed',
);
hasGivenDeprecationWarning.current = true;
}

const i18n = useI18n();

const isDisabled = disabled || loading;
Expand All @@ -121,6 +137,7 @@ export function Button({
isDisabled && styles.disabled,
loading && styles.loading,
plain && styles.plain,
pressed && !disabled && !url && styles.pressed,
monochrome && styles.monochrome,
size && size !== DEFAULT_SIZE && styles[variationName('size', size)],
textAlign && styles[variationName('textAlign', textAlign)],
Expand Down Expand Up @@ -206,6 +223,8 @@ export function Button({
);
}

const ariaPressedStatus = pressed !== undefined ? pressed : ariaPressed;

return (
<button
id={id}
Expand All @@ -222,7 +241,7 @@ export function Button({
aria-label={accessibilityLabel}
aria-controls={ariaControls}
aria-expanded={ariaExpanded}
aria-pressed={ariaPressed}
aria-pressed={ariaPressedStatus}
role={loading ? 'alert' : undefined}
aria-busy={loading ? true : undefined}
>
Expand Down
33 changes: 33 additions & 0 deletions src/components/Button/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,39 @@ Use for plain or monochrome buttons that could have a long length and should be
</Button>
```

### Pressed button

<!-- example-for: web -->

Buttons are sometimes used as a toggle for other parts of the user interface.

```jsx
function PressedButton() {
const [isFirstButtonActive, setIsFirstButtonActive] = useState(true);

const handleFirstButtonClick = useCallback(() => {
if (isFirstButtonActive) return;
setIsFirstButtonActive(true);
}, [isFirstButtonActive]);

const handleSecondButtonClick = useCallback(() => {
if (!isFirstButtonActive) return;
setIsFirstButtonActive(false);
}, [isFirstButtonActive]);

return (
<ButtonGroup segmented>
<Button pressed={isFirstButtonActive} onClick={handleFirstButtonClick}>
First button
</Button>
<Button pressed={!isFirstButtonActive} onClick={handleSecondButtonClick}>
Second button
</Button>
</ButtonGroup>
);
}
```

### Disabled state

Use for actions that aren’t currently available. The surrounding interface should make it clear why the button is disabled and what needs to be done to enable it.
Expand Down
46 changes: 46 additions & 0 deletions src/components/Button/tests/Button.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import {PlusMinor} from '@shopify/polaris-icons';
import {mountWithAppProvider, trigger} from 'test-utilities/legacy';
import {mountWithApp} from 'test-utilities';
import {UnstyledLink, Icon, Spinner} from 'components';
import {Button, IconWrapper} from '../Button';

Expand Down Expand Up @@ -191,8 +192,14 @@ describe('<Button />', () => {

describe('ariaPressed', () => {
it('sets an aria-pressed on the button', () => {
const warningSpy = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});

const button = mountWithAppProvider(<Button ariaPressed />);
expect(button.find('button').prop('aria-pressed')).toBeTruthy();

warningSpy.mockRestore();
});
});

Expand Down Expand Up @@ -280,4 +287,43 @@ describe('<Button />', () => {
expect(spy).toHaveBeenCalled();
});
});

describe('pressed', () => {
const buttonPressedClasses = 'Button pressed';

it('outputs a pressed button', () => {
const button = mountWithApp(<Button pressed />);
expect(button).toContainReactComponent('button', {
className: buttonPressedClasses,
});
});

it("doesn't output a pressed button when disabled", () => {
const button = mountWithApp(<Button pressed disabled />);
expect(button).not.toContainReactComponent('button', {
className: buttonPressedClasses,
});
});

it("doesn't output a pressed button when a url is present", () => {
const button = mountWithApp(<Button pressed url="/" />);
expect(button).not.toContainReactComponent('button', {
className: buttonPressedClasses,
});
});
});

describe('deprecations', () => {
it('warns the ariaPressed prop has been replaced', () => {
const warningSpy = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});
mountWithApp(<Button ariaPressed />);

expect(warningSpy).toHaveBeenCalledWith(
'Deprecation: The ariaPressed prop has been replaced with pressed',
);
warningSpy.mockRestore();
});
});
});
Loading