diff --git a/UNRELEASED.md b/UNRELEASED.md index 8d14e8526f7..4cc954f40ae 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -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 diff --git a/src/components/Button/Button.scss b/src/components/Button/Button.scss index 3ac1f1c7efe..4305ec0ba0c 100644 --- a/src/components/Button/Button.scss +++ b/src/components/Button/Button.scss @@ -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; @@ -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 { @@ -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 { @@ -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 { @@ -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. @@ -146,6 +256,7 @@ $spinner-size: rem(20px); box-shadow: none; color: color('blue'); + &.pressed, &:hover, &:focus, &:active { @@ -157,6 +268,7 @@ $spinner-size: rem(20px); text-decoration: underline; } + &.pressed, &:focus { @include high-contrast-button-outline(none); } @@ -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; } @@ -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; } } } diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index c61f2368630..577c6273d79 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -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'; @@ -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 */ @@ -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; @@ -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; @@ -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)], @@ -206,6 +223,8 @@ export function Button({ ); } + const ariaPressedStatus = pressed !== undefined ? pressed : ariaPressed; + return ( ``` +### Pressed button + + + +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 ( + + + + + ); +} +``` + ### 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. diff --git a/src/components/Button/tests/Button.test.tsx b/src/components/Button/tests/Button.test.tsx index aac21683bd1..4aeeb3e9f22 100644 --- a/src/components/Button/tests/Button.test.tsx +++ b/src/components/Button/tests/Button.test.tsx @@ -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'; @@ -191,8 +192,14 @@ describe('