diff --git a/.chromatic/config.js b/.chromatic/config.js index 1315a38aad9..4e76b4ecf79 100644 --- a/.chromatic/config.js +++ b/.chromatic/config.js @@ -22,7 +22,7 @@ addParameters({ addDecorator(withA11y); addDecorator(story => ( - + {story()} )); diff --git a/.circleci/config.yml b/.circleci/config.yml index 415ec72c870..d6147e60f40 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -4,7 +4,7 @@ orbs: jobs: install: docker: - - image: circleci/node:12.10 + - image: circleci/node:12 resource_class: large working_directory: ~/react-spectrum @@ -40,7 +40,7 @@ jobs: test: docker: - - image: circleci/node:12.10 + - image: circleci/node:12 working_directory: ~/react-spectrum steps: @@ -53,7 +53,7 @@ jobs: test_17: docker: - - image: circleci/node:12.10 + - image: circleci/node:12 working_directory: ~/react-spectrum steps: @@ -66,7 +66,7 @@ jobs: lint: docker: - - image: circleci/node:12.10 + - image: circleci/node:12 working_directory: ~/react-spectrum steps: @@ -79,7 +79,7 @@ jobs: storybook: docker: - - image: circleci/node:12.10 + - image: circleci/node:12 resource_class: large working_directory: ~/react-spectrum @@ -98,7 +98,7 @@ jobs: storybook-17: docker: - - image: circleci/node:12.10 + - image: circleci/node:12 resource_class: large working_directory: ~/react-spectrum @@ -117,7 +117,7 @@ jobs: docs: docker: - - image: circleci/node:12.10 + - image: circleci/node:12 resource_class: xlarge working_directory: ~/react-spectrum @@ -136,7 +136,7 @@ jobs: docs-production: docker: - - image: circleci/node:12.10 + - image: circleci/node:12 resource_class: large working_directory: ~/react-spectrum @@ -176,7 +176,7 @@ jobs: comment: docker: - - image: circleci/node:12.10 + - image: circleci/node:12 working_directory: ~/react-spectrum steps: - checkout @@ -192,7 +192,7 @@ jobs: publish-nightly: docker: - - image: circleci/node:12.10 + - image: circleci/node:12 resource_class: xlarge working_directory: ~/react-spectrum steps: diff --git a/.storybook/config.js b/.storybook/config.js index 1a40f7308f1..a1dd6b88ca6 100644 --- a/.storybook/config.js +++ b/.storybook/config.js @@ -22,7 +22,7 @@ addParameters({ addDecorator(withA11y); addDecorator(story => ( - + {story()} )); diff --git a/package.json b/package.json index 8b3ed801ca1..47a7339cf26 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "@testing-library/react": "^10.4.9", "@testing-library/react-hooks": "^3.4.1", "@testing-library/user-event": "^12.1.3", - "@types/react": "16.9.23", + "@types/react": "^16.9.23", "@types/storybook__react": "^4.0.1", "@typescript-eslint/eslint-plugin": "^2.28.0", "@typescript-eslint/parser": "^2.28.0", diff --git a/packages/@adobe/spectrum-css-temp/components/slider/index.css b/packages/@adobe/spectrum-css-temp/components/slider/index.css index 493bc910e3e..83c0134c6e3 100644 --- a/packages/@adobe/spectrum-css-temp/components/slider/index.css +++ b/packages/@adobe/spectrum-css-temp/components/slider/index.css @@ -53,8 +53,6 @@ governing permissions and limitations under the License. /* Don't let z-index'd child elements float above other things on the page */ z-index: 1; display: block; - min-height: var(--spectrum-slider-height); - min-width: var(--spectrum-slider-min-width); user-select: none; } @@ -66,28 +64,32 @@ governing permissions and limitations under the License. position: relative; z-index: auto; + min-block-size: var(--spectrum-slider-height); + min-inline-size: var(--spectrum-slider-min-width); + /* These calculations prevent the track from spilling outside of the control */ - width: calc(100% - calc(var(--spectrum-slider-controls-margin) * 2)); - margin-left: var(--spectrum-slider-controls-margin); - min-height: var(--spectrum-slider-height); + inline-size: calc(100% - calc(var(--spectrum-slider-controls-margin) * 2)); + margin-inline-start: var(--spectrum-slider-controls-margin); + min-block-size: var(--spectrum-slider-height); vertical-align: top; } + .spectrum-Slider-track, .spectrum-Slider-buffer, .spectrum-Slider-ramp, .spectrum-Slider-fill { - height: var(--spectrum-slider-track-height); + block-size: var(--spectrum-slider-track-height); box-sizing: border-box; position: absolute; z-index: 1; - top: calc(var(--spectrum-slider-height) / 2); - left: 0; - right: auto; + inset-block-start: calc(var(--spectrum-slider-height) / 2); + inset-inline-start: 0; + inset-inline-end: auto; - margin-top: calc(var(--spectrum-slider-fill-track-height) / -2); + margin-block-start: calc(var(--spectrum-slider-fill-track-height) / -2); pointer-events: none; } @@ -95,43 +97,49 @@ governing permissions and limitations under the License. .spectrum-Slider-track, .spectrum-Slider-buffer, .spectrum-Slider-fill { - padding: 0 var(--spectrum-slider-track-handleoffset) 0 0; - margin-left: var(--spectrum-slider-track-margin-offset); + padding-block: 0; + padding-inline: 0 var(--spectrum-slider-track-handleoffset); + margin-inline-start: var(--spectrum-slider-track-margin-offset); &::before { content: ''; display: block; - height: 100%; + block-size: 100%; border-radius: var(--spectrum-slider-track-border-radius); } } .spectrum-Slider-fill { - margin-left: 0; - padding: 0 0 0 calc(var(--spectrum-slider-controls-margin) + var(--spectrum-slider-track-handleoffset)); + margin-inline-start: 0; + padding-block: 0; + padding-inline: calc(var(--spectrum-slider-controls-margin) + var(--spectrum-slider-track-handleoffset)) 0; } .spectrum-Slider-fill--right { - padding: 0 calc(var(--spectrum-slider-controls-margin) + var(--spectrum-slider-track-handleoffset)) 0 0; + padding-block: 0; + padding-inline: 0 calc(var(--spectrum-slider-controls-margin) + var(--spectrum-slider-track-handleoffset)); } .spectrum-Slider-buffer { - padding: 0 var(--spectrum-slider-track-handleoffset) 0 0; + padding-block: 0; + padding-inline: 0 var(--spectrum-slider-track-handleoffset); } .spectrum-Slider-track ~ .spectrum-Slider-track, .spectrum-Slider-buffer ~ .spectrum-Slider-buffer { - left: auto; - right: var(--spectrum-slider-range-track-reset); - padding: 0 0 0 var(--spectrum-slider-track-handleoffset); - margin-left: var(--spectrum-slider-range-track-reset); - margin-right: var(--spectrum-slider-track-margin-offset); + inset-inline-start: auto; + inset-inline-end: var(--spectrum-slider-range-track-reset); + padding-block: 0; + padding-inline: var(--spectrum-slider-track-handleoffset) 0; + margin-inline-start: var(--spectrum-slider-range-track-reset); + margin-inline-end: var(--spectrum-slider-track-margin-offset); } .spectrum-Slider-buffer ~ .spectrum-Slider-buffer { - margin-right: var(--spectrum-slider-range-track-reset); - padding: 0 0 0 var(--spectrum-slider-track-middle-handleoffset); + margin-inline-end: var(--spectrum-slider-range-track-reset); + padding-block: 0; + padding-inline: var(--spectrum-slider-track-middle-handleoffset) 0; &:after { display: none; @@ -145,22 +153,29 @@ governing permissions and limitations under the License. .spectrum-Slider-track { &:first-of-type { - padding: 0 var(--spectrum-slider-track-handleoffset) 0 0; - left: var(--spectrum-slider-range-track-reset); - right: auto; - margin-left: var(--spectrum-slider-track-margin-offset); + padding-block: 0; + padding-inline: 0 var(--spectrum-slider-track-handleoffset); + inset-inline-start: var(--spectrum-slider-range-track-reset); + inset-inline-end: auto; + margin-inline-start: var(--spectrum-slider-track-margin-offset); } - & { - padding: 0 var(--spectrum-slider-track-middle-handleoffset) 0 var(--spectrum-slider-track-middle-handleoffset); - left: auto; - right: auto; - margin: var(--spectrum-slider-range-track-reset); + + /* Force specificity otherwise the ~ rules above override */ + &:dir(ltr), + &:dir(rtl) { + padding-block: 0; + padding-inline: var(--spectrum-slider-track-middle-handleoffset) var(--spectrum-slider-track-middle-handleoffset); + inset-inline-start: auto; + inset-inline-end: auto; + margin-inline: var(--spectrum-slider-range-track-reset); + margin-block: var(--spectrum-slider-range-track-reset); } &:last-of-type { - padding: 0 0 0 var(--spectrum-slider-track-handleoffset); - left: auto; - right: var(--spectrum-slider-range-track-reset); - margin-right: var(--spectrum-slider-track-margin-offset); + padding-block: 0; + padding-inline: var(--spectrum-slider-track-handleoffset) 0; + inset-inline-start: auto; + inset-inline-end: var(--spectrum-slider-range-track-reset); + margin-inline-end: var(--spectrum-slider-track-margin-offset); } } } @@ -171,33 +186,37 @@ governing permissions and limitations under the License. } .spectrum-Slider-ramp { - margin-top: var(--spectrum-slider-ramp-margin-top); - height: var(--spectrum-slider-ramp-track-height); + margin-block-start: var(--spectrum-slider-ramp-margin-top); + block-size: var(--spectrum-slider-ramp-track-height); position: absolute; - left: var(--spectrum-slider-track-margin-offset); - right: var(--spectrum-slider-track-margin-offset); - top: calc(var(--spectrum-slider-ramp-track-height) / 2); + inset-inline-start: var(--spectrum-slider-track-margin-offset); + inset-inline-end: var(--spectrum-slider-track-margin-offset); + inset-block-start: calc(var(--spectrum-slider-ramp-track-height) / 2); svg { - width: 100%; - height: 100%; + inline-size: 100%; + block-size: 100%; + + /* Flip the ramp automatically for RTL */ + transform: logical rotate(0deg); } } .spectrum-Slider-handle { position: absolute; - left: 0; - top: calc(var(--spectrum-slider-height) / 2); + inset-inline-start: 0; + inset-block-start: calc(var(--spectrum-slider-height) / 2); z-index: 2; display: inline-block; box-sizing: border-box; - width: var(--spectrum-slider-handle-width); - height: var(--spectrum-slider-handle-height); + inline-size: var(--spectrum-slider-handle-width); + block-size: var(--spectrum-slider-handle-height); - margin: var(--spectrum-slider-handle-margin-top) 0 0 calc(var(--spectrum-slider-handle-width) / -2); + margin-block: var(--spectrum-slider-handle-margin-top) 0; + margin-inline: calc(var(--spectrum-slider-handle-width) / -2) 0; border-width: var(--spectrum-slider-handle-border-size); border-style: solid; @@ -207,15 +226,19 @@ governing permissions and limitations under the License. transition: border-width var(--spectrum-slider-animation-duration) ease-in-out; outline: none; - cursor: pointer; - cursor: grab; + /* cursor: pointer; */ + /* cursor: grab; */ &:active, - &.is-focused, &.is-dragged { - /*border-width: var(--spectrum-slider-handle-border-size-down);*/ - cursor: ns-resize; - cursor: grabbing; + border-width: var(--spectrum-slider-handle-border-size-down); + /* cursor: ns-resize; */ + /* cursor: grabbing; */ + } + + &:active, + $.is-dragged { + } &:active, @@ -256,12 +279,12 @@ governing permissions and limitations under the License. /* Remove the margin for input in Firefox and Safari. */ margin: 0; - width: var(--spectrum-slider-handle-width); - height: var(--spectrum-slider-handle-height); + inline-size: var(--spectrum-slider-handle-width); + block-size: var(--spectrum-slider-handle-height); padding: 0; position: absolute; - top: var(--spectrum-slider-input-top); - left: var(--spectrum-slider-input-left); + inset-block-start: var(--spectrum-slider-input-top); + inset-inline-start: var(--spectrum-slider-input-left); overflow: hidden; opacity: .000001; cursor: default; @@ -278,9 +301,9 @@ governing permissions and limitations under the License. display: flex; position: relative; - width: auto; + inline-size: auto; - padding-top: var(--spectrum-fieldlabel-padding-top); + padding-block-start: var(--spectrum-fieldlabel-padding-top); font-size: var(--spectrum-text-size-text-label); line-height: var(--spectrum-line-height-text-label); @@ -288,19 +311,21 @@ governing permissions and limitations under the License. .spectrum-Slider-label, .spectrum-Dial-label { - padding-left: 0; + padding-inline-start: 0; flex-grow: 1; } .spectrum-Slider-value, .spectrum-Dial-value { flex-grow: 0; - padding-right: 0; + padding-inline-end: 0; cursor: default; + font-feature-settings: "tnum"; + text-align: end; } .spectrum-Slider-value { - margin-left: var(--spectrum-slider-label-gap-x); + margin-inline-start: var(--spectrum-slider-label-gap-x); } .spectrum-Slider-ticks { @@ -310,22 +335,22 @@ governing permissions and limitations under the License. z-index: 0; margin: 0 var(--spectrum-slider-track-margin-offset); - margin-top: calc(var(--spectrum-slider-tick-mark-height) + calc(var(--spectrum-slider-track-height) / 2)); + margin-block-start: calc(var(--spectrum-slider-tick-mark-height) + calc(var(--spectrum-slider-track-height) / 2)); } .spectrum-Slider-tick { position: relative; - width: var(--spectrum-slider-tick-mark-width); + inline-size: var(--spectrum-slider-tick-mark-width); &:after { display: block; position: absolute; - top: 0; - left: calc(50% - calc(var(--spectrum-slider-tick-mark-width) / 2)); + inset-block-start: 0; + inset-inline-start: calc(50% - calc(var(--spectrum-slider-tick-mark-width) / 2)); content: ''; - width: var(--spectrum-slider-tick-mark-width); - height: var(--spectrum-slider-tick-mark-height); + inline-size: var(--spectrum-slider-tick-mark-width); + block-size: var(--spectrum-slider-tick-mark-height); border-radius: var(--spectrum-slider-tick-mark-border-radius); } @@ -335,7 +360,8 @@ governing permissions and limitations under the License. align-items: center; justify-content: center; - margin: var(--spectrum-slider-label-gap-x) calc(var(--spectrum-slider-label-gap-x) * -1) 0 calc(var(--spectrum-slider-label-gap-x) * -1); + margin-block: var(--spectrum-slider-label-gap-x) 0; + margin-inline: calc(var(--spectrum-slider-label-gap-x) * -1) calc(var(--spectrum-slider-label-gap-x) * -1); font-size: var(--spectrum-text-size-text-label); line-height: var(--spectrum-line-height-text-label); @@ -346,34 +372,35 @@ governing permissions and limitations under the License. .spectrum-Slider-tickLabel { display: block; position: absolute; - margin: var(--spectrum-slider-label-gap-x) 0 0 0; + margin-block: var(--spectrum-slider-label-gap-x) 0; + margin-inline: 0; } } &:first-of-type { .spectrum-Slider-tickLabel { - left: 0; + inset-inline-start: 0; } } &:last-of-type { .spectrum-Slider-tickLabel { - right: 0; + inset-inline-end: 0; } } } .spectrum-Slider--color { .spectrum-Slider-labelContainer { - padding-bottom: var(--spectrum-fieldlabel-padding-bottom); + padding-block-end: var(--spectrum-fieldlabel-padding-bottom); } .spectrum-Slider-controls, .spectrum-Slider-controls::before, .spectrum-Slider-track { - min-height: var(--spectrum-slider-color-min-height); - height: var(--spectrum-slider-color-track-height); - margin-left: var(--spectrum-slider-color-track-margin-left); - width: 100%; + min-block-size: var(--spectrum-slider-color-min-height); + block-size: var(--spectrum-slider-color-track-height); + margin-inline-start: var(--spectrum-slider-color-track-margin-left); + inline-size: 100%; } .spectrum-Slider-controls::before { display: block; @@ -381,14 +408,14 @@ governing permissions and limitations under the License. } .spectrum-Slider-controls::before, .spectrum-Slider-track { - top: var(--spectrum-slider-color-track-top); + inset-block-start: var(--spectrum-slider-color-track-top); padding: var(--spectrum-slider-color-track-padding); - margin-top: var(--spectrum-slider-color-track-margin-top); - margin-right: var(--spectrum-slider-color-track-margin-right); + margin-block-start: var(--spectrum-slider-color-track-margin-top); + margin-inline-end: var(--spectrum-slider-color-track-margin-right); border-radius: var(--spectrum-alias-border-radius-regular); } .spectrum-Slider-handle { - top: var(--spectrum-slider-color-handle-top); + inset-block-start: var(--spectrum-slider-color-handle-top); } } @@ -397,15 +424,15 @@ governing permissions and limitations under the License. display: inline-flex; flex-direction: column; - height: auto; - min-width: var(--spectrum-dial-min-height); - min-height: var(--spectrum-dial-min-height); - width: var(--spectrum-dial-container-width); + block-size: auto; + min-inline-size: var(--spectrum-dial-min-height); + min-block-size: var(--spectrum-dial-min-height); + inline-size: var(--spectrum-dial-container-width); } .spectrum-Dial-labelContainer { @inherit: .spectrum-Slider-labelContainer; - margin-bottom: var(--spectrum-dial-label-gap-y); + margin-block-end: var(--spectrum-dial-label-gap-y); } .spectrum-Dial-label { @@ -417,9 +444,9 @@ governing permissions and limitations under the License. .spectrum-Dial-controls { @inherit: .spectrum-Slider-controls; - width: var(--spectrum-dial-width); - height: var(--spectrum-dial-width); - min-height: var(--spectrum-dial-controls-min-height); + inline-size: var(--spectrum-dial-width); + block-size: var(--spectrum-dial-width); + min-block-size: var(--spectrum-dial-controls-min-height); border-radius: var(--spectrum-dial-border-radius); position: relative; @@ -432,37 +459,37 @@ governing permissions and limitations under the License. &::before, &::after { content: ''; - width: calc(var(--spectrum-slider-tick-mark-width) * 2); - height: var(--spectrum-slider-tick-mark-width); + inline-size: calc(var(--spectrum-slider-tick-mark-width) * 2); + block-size: var(--spectrum-slider-tick-mark-width); border-radius: var(--spectrum-slider-tick-mark-border-radius); position: absolute; - bottom: 0; + inset-block-end: 0; } &::before { - left: auto; - right: calc(var(--spectrum-slider-tick-mark-width) * -1); + inset-inline-start: auto; + inset-inline-end: calc(var(--spectrum-slider-tick-mark-width) * -1); transform: rotate(var(--spectrum-dial-min-max-tick-angles)); } &::after { - left: calc(var(--spectrum-slider-tick-mark-width) * -1); - transform: rotate(calc(var(--spectrum-dial-min-max-tick-angles) * -1)); + inset-inline-start: calc(var(--spectrum-slider-tick-mark-width) * -1); + transform: rotate(calc(-1 * var(--spectrum-dial-min-max-tick-angles))); } } .spectrum-Dial-handle { @inherit: .spectrum-Slider-handle; - width: var(--spectrum-dial-handle-size); - height: var(--spectrum-dial-handle-size); + inline-size: var(--spectrum-dial-handle-size); + block-size: var(--spectrum-dial-handle-size); border-width: var(--spectrum-slider-handle-border-size); box-shadow: none; position: absolute; - top: var(--spectrum-dial-handle-position); - left: var(--spectrum-dial-handle-position); - right: var(--spectrum-dial-handle-position); - bottom: var(--spectrum-dial-handle-position); + inset-block-start: var(--spectrum-dial-handle-position); + inset-inline-start: var(--spectrum-dial-handle-position); + inset-inline-end: var(--spectrum-dial-handle-position); + inset-block-end: var(--spectrum-dial-handle-position); border-radius: var(--spectrum-dial-border-radius); - transform: rotate(calc(var(--spectrum-dial-min-max-tick-angles) * -1)); - cursor: default; + transform: rotate(calc(-1 * var(--spectrum-dial-min-max-tick-angles))); + cursor: pointer; cursor: grab; transition: background-color var(--spectrum-slider-animation-duration) ease-in-out; @@ -470,10 +497,10 @@ governing permissions and limitations under the License. &::after { content: ''; position: absolute; - top: 50%; - left: calc(var(--spectrum-slider-tick-mark-width) * -1); - width: var(--spectrum-dial-handle-marker-width); - height: var(--spectrum-dial-handle-marker-height); + inset-block-start: 50%; + inset-inline-start: calc(var(--spectrum-slider-tick-mark-width) * -1); + inline-size: var(--spectrum-dial-handle-marker-width); + block-size: var(--spectrum-dial-handle-marker-height); border-radius: var(--spectrum-dial-handle-marker-border-radius); transform: translateY(-50%); transition: background-color var(--spectrum-slider-animation-duration) ease-in-out; @@ -490,16 +517,16 @@ governing permissions and limitations under the License. .spectrum-Dial-input { @inherit: .spectrum-Slider-input; - width: var(--spectrum-dial-handle-size); - height: var(--spectrum-dial-handle-size); - left: 0; - top: 0; + inline-size: var(--spectrum-dial-handle-size); + block-size: var(--spectrum-dial-handle-size); + inset-inline-start: 0; + inset-block-start: 0; } .spectrum-Dial--small { .spectrum-Dial-controls { - width: var(--spectrum-dial-small-width); - height: var(--spectrum-dial-small-height); + inline-size: var(--spectrum-dial-small-width); + block-size: var(--spectrum-dial-small-height); } } diff --git a/packages/@adobe/spectrum-css-temp/components/slider/skin.css b/packages/@adobe/spectrum-css-temp/components/slider/skin.css index 8ed5b802e45..a7cce7a682b 100644 --- a/packages/@adobe/spectrum-css-temp/components/slider/skin.css +++ b/packages/@adobe/spectrum-css-temp/components/slider/skin.css @@ -21,6 +21,8 @@ governing permissions and limitations under the License. .spectrum-Slider-track { &::before { background: var(--spectrum-slider-track-color); + background-size: var(--spectrum-track-background-size); + background-position: var(--spectrum-track-background-position); } } @@ -126,8 +128,8 @@ governing permissions and limitations under the License. background-position: 0 0, 0 var(--spectrum-global-dimension-static-size-100), - var(--spectrum-global-dimension-static-size-100) calc(var(--spectrum-global-dimension-static-size-100) * -1), - calc(var(--spectrum-global-dimension-static-size-100) * -1) 0; + var(--spectrum-global-dimension-static-size-100) calc(-1 * var(--spectrum-global-dimension-static-size-100)), + calc(-1 * var(--spectrum-global-dimension-static-size-100)) 0; z-index: 0; } .spectrum-Slider-track { @@ -302,3 +304,22 @@ governing permissions and limitations under the License. } } } + +.spectrum-Slider--label-side { + display: flex; + gap: 7px; + align-items: center; + + & > * { + display: inline-block; + } + + & .spectrum-Slider-labelContainer { + padding-top: 0; + flex-shrink: 0; + } + + & .spectrum-Slider-label { + margin-inline-end: var(--spectrum-slider-label-gap-x); + } +} diff --git a/packages/@react-aria/i18n/src/useNumberFormatter.ts b/packages/@react-aria/i18n/src/useNumberFormatter.ts index ea64ce6fcd7..dbe6d1ea994 100644 --- a/packages/@react-aria/i18n/src/useNumberFormatter.ts +++ b/packages/@react-aria/i18n/src/useNumberFormatter.ts @@ -10,10 +10,14 @@ * governing permissions and limitations under the License. */ +import {numberFormatSignDisplayPolyfill} from './utils'; import {useLocale} from './context'; let formatterCache = new Map(); +// @ts-ignore +const supportsSignDisplay = (new Intl.NumberFormat('de-DE', {signDisplay: 'exceptZero'})).resolvedOptions().signDisplay === 'exceptZero'; + /** * Provides localized number formatting for the current locale. Automatically updates when the locale changes, * and handles caching of the number formatter for performance. @@ -21,13 +25,23 @@ let formatterCache = new Map(); */ export function useNumberFormatter(options?: Intl.NumberFormatOptions): Intl.NumberFormat { let {locale} = useLocale(); - + let cacheKey = locale + (options ? Object.entries(options).sort((a, b) => a[0] < b[0] ? -1 : 1).join() : ''); if (formatterCache.has(cacheKey)) { return formatterCache.get(cacheKey); } let numberFormatter = new Intl.NumberFormat(locale, options); - formatterCache.set(cacheKey, numberFormatter); + // @ts-ignore + let {signDisplay} = options || {}; + formatterCache.set(cacheKey, (!supportsSignDisplay && signDisplay != null) ? new Proxy(numberFormatter, { + get(target, property) { + if (property === 'format') { + return (v) => numberFormatSignDisplayPolyfill(numberFormatter, signDisplay, v); + } else { + return target[property]; + } + } + }) : numberFormatter); return numberFormatter; } diff --git a/packages/@react-aria/i18n/src/utils.ts b/packages/@react-aria/i18n/src/utils.ts index 9a2e5b0285e..70cfcdfd72b 100644 --- a/packages/@react-aria/i18n/src/utils.ts +++ b/packages/@react-aria/i18n/src/utils.ts @@ -31,3 +31,36 @@ export function isRTL(locale: string) { let lang = locale.split('-')[0]; return RTL_LANGS.has(lang); } + +export function numberFormatSignDisplayPolyfill(numberFormat: Intl.NumberFormat, signDisplay: 'always' | 'exceptZero' | 'auto' | 'never', num: number) { + if (signDisplay === 'auto') { + return numberFormat.format(num); + } else if (signDisplay === 'never') { + return numberFormat.format(Math.abs(num)); + } else { + let needsPositiveSign = false; + if (signDisplay === 'always') { + needsPositiveSign = num > 0 || Object.is(num, 0); + } else if (signDisplay === 'exceptZero') { + if (Object.is(num, -0) || Object.is(num, 0)) { + num = Math.abs(num); + } else { + needsPositiveSign = num > 0; + } + } + + if (needsPositiveSign) { + let negative = numberFormat.format(-num); + let noSign = numberFormat.format(num); + // ignore RTL/LTR marker character + let minus = negative.replace(noSign, '').replace(/\u200e|\u061C/, ''); + if ([...minus].length !== 1) { + console.warn('@react-aria/i18n polyfill for NumberFormat signDisplay: Unsupported case'); + } + let positive = negative.replace(noSign, '!!!').replace(minus, '+').replace('!!!', noSign); + return positive; + } else { + return numberFormat.format(num); + } + } +} diff --git a/packages/@react-aria/i18n/test/numberFormatSignDisplayPolyfill.test.js b/packages/@react-aria/i18n/test/numberFormatSignDisplayPolyfill.test.js new file mode 100644 index 00000000000..be2b1ea5564 --- /dev/null +++ b/packages/@react-aria/i18n/test/numberFormatSignDisplayPolyfill.test.js @@ -0,0 +1,40 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {numberFormatSignDisplayPolyfill} from '../src/utils'; + +function verify(locale, options, signDisplay, v) { + let a = new Intl.NumberFormat(locale, options); + let b = new Intl.NumberFormat(locale, {...options, signDisplay}); + + expect(b.format(v)).toBe(numberFormatSignDisplayPolyfill(a, signDisplay, v)); +} + +let signDisplayValues = ['always', 'auto', 'never', 'exceptZero']; +let localeValues = ['de-DE', 'ar-AE', 'fa', 'he-IL']; +let optionsValues = [{}, {style: 'unit', unit: 'celsius'}, {style: 'currency', currency: 'USD', currencyDisplay: 'name'}]; +let numValues = [-123, -1, -0, 0, +0, 1, 123]; + +describe('numberFormatSignDisplayPolyfill', () => { + for (let signDisplay of signDisplayValues) { + for (let locale of localeValues) { + for (let options of optionsValues) { + for (let num of numValues) { + // eslint-disable-next-line no-nested-ternary + it(`${locale} - ${signDisplay} - ${JSON.stringify(options)} - ${Object.is(num, +0) ? '+0' : Object.is(num, -0) ? '-0' : num}`, () => { + verify(locale, options, signDisplay, num); + }); + } + } + } + } +}); diff --git a/packages/@react-aria/slider/src/useSlider.ts b/packages/@react-aria/slider/src/useSlider.ts index 19ed02ddec8..7644eaa95a5 100644 --- a/packages/@react-aria/slider/src/useSlider.ts +++ b/packages/@react-aria/slider/src/useSlider.ts @@ -16,6 +16,7 @@ import {sliderIds} from './utils'; import {SliderProps} from '@react-types/slider'; import {SliderState} from '@react-stately/slider'; import {useLabel} from '@react-aria/label'; +import {useLocale} from '@react-aria/i18n'; interface SliderAria { /** Props for the label element. */ @@ -45,20 +46,20 @@ export function useSlider( ): SliderAria { const {labelProps, fieldProps} = useLabel(props); - const isSliderEditable = !(props.isDisabled || props.isReadOnly); - // Attach id of the label to the state so it can be accessed by useSliderThumb. sliderIds.set(state, labelProps.id ?? fieldProps.id); + let {direction} = useLocale(); + // When the user clicks or drags the track, we want the motion to set and drag the // closest thumb. Hence we also need to install useDrag1D() on the track element. // Here, we keep track of which index is the "closest" to the drag start point. // It is set onMouseDown; see trackProps below. const realTimeTrackDraggingIndex = useRef(undefined); const isTrackDragging = useRef(false); - const {onMouseDown, onMouseEnter, onMouseOut} = useDrag1D({ + const {onMouseDown, onMouseEnter, onMouseOut, onKeyDown} = useDrag1D({ containerRef: trackRef as any, - reverse: false, + reverse: direction === 'rtl', orientation: 'horizontal', onDrag: (dragging) => { if (realTimeTrackDraggingIndex.current !== undefined) { @@ -79,6 +80,18 @@ export function useSlider( realTimeTrackDraggingIndex.current = undefined; } } + }, + onIncrement() { + state.setThumbValue(state.focusedThumb, state.getThumbValue(state.focusedThumb) + state.step); + }, + onDecrement() { + state.setThumbValue(state.focusedThumb, state.getThumbValue(state.focusedThumb) - state.step); + }, + onIncrementToMax() { + state.setThumbValue(state.focusedThumb, state.getThumbMaxValue(state.focusedThumb)); + }, + onDecrementToMin() { + state.setThumbValue(state.focusedThumb, state.getThumbMinValue(state.focusedThumb)); } }); @@ -95,12 +108,15 @@ export function useSlider( trackProps: mergeProps({ onMouseDown: (e: React.MouseEvent) => { // We only trigger track-dragging if the user clicks on the track itself. - if (trackRef.current && isSliderEditable) { + if (trackRef.current && !props.isDisabled) { // Find the closest thumb const trackPosition = trackRef.current.getBoundingClientRect().left; const clickPosition = e.clientX; const offset = clickPosition - trackPosition; - const percent = offset / trackRef.current.offsetWidth; + let percent = offset / trackRef.current.offsetWidth; + if (direction === 'rtl') { + percent = 1 - percent; + } const value = state.getPercentValue(percent); // Only compute the diff for thumbs that are editable, as only they can be dragged @@ -127,7 +143,7 @@ export function useSlider( } } }, { - onMouseDown, onMouseEnter, onMouseOut + onMouseDown, onMouseEnter, onMouseOut, onKeyDown }) }; } diff --git a/packages/@react-aria/slider/src/useSliderThumb.ts b/packages/@react-aria/slider/src/useSliderThumb.ts index 0c54d1ee7f6..90f1dfe2d64 100644 --- a/packages/@react-aria/slider/src/useSliderThumb.ts +++ b/packages/@react-aria/slider/src/useSliderThumb.ts @@ -5,6 +5,7 @@ import {SliderState} from '@react-stately/slider'; import {SliderThumbProps} from '@react-types/slider'; import {useFocusable} from '@react-aria/focus'; import {useLabel} from '@react-aria/label'; +import {useLocale} from '@react-aria/i18n'; interface SliderThumbAria { /** Props for the range input. */ @@ -30,18 +31,19 @@ interface SliderThumbOptions extends SliderThumbProps { */ export function useSliderThumb( opts: SliderThumbOptions, - state: SliderState, + state: SliderState ): SliderThumbAria { const { index, isRequired, isDisabled, - isReadOnly, validationState, trackRef, inputRef } = opts; + let {direction} = useLocale(); + let labelId = sliderIds.get(state); const {labelProps, fieldProps} = useLabel({ ...opts, @@ -49,7 +51,6 @@ export function useSliderThumb( }); const value = state.values[index]; - const isEditable = !(isDisabled || isReadOnly); const focusInput = useCallback(() => { if (inputRef.current) { @@ -67,7 +68,7 @@ export function useSliderThumb( const draggableProps = useDrag1D({ containerRef: trackRef as any, - reverse: false, + reverse: direction === 'rtl', orientation: 'horizontal', onDrag: (dragging) => { state.setThumbDragging(index, dragging); @@ -80,7 +81,7 @@ export function useSliderThumb( }); // Immediately register editability with the state - state.setThumbEditable(index, isEditable); + state.setThumbEditable(index, !isDisabled); const {focusableProps} = useFocusable( mergeProps(opts, { @@ -97,12 +98,11 @@ export function useSliderThumb( return { inputProps: mergeProps(focusableProps, fieldProps, { type: 'range', - tabIndex: isEditable ? 0 : undefined, + tabIndex: !isDisabled ? 0 : undefined, min: state.getThumbMinValue(index), max: state.getThumbMaxValue(index), step: state.step, value: value, - readOnly: isReadOnly, disabled: isDisabled, 'aria-orientation': 'horizontal', 'aria-valuetext': state.getThumbValueLabel(index), @@ -113,7 +113,7 @@ export function useSliderThumb( state.setThumbValue(index, parseFloat(e.target.value)); } }), - thumbProps: isEditable ? mergeProps({ + thumbProps: !isDisabled ? mergeProps({ onMouseDown: draggableProps.onMouseDown, onMouseEnter: draggableProps.onMouseEnter, onMouseOut: draggableProps.onMouseOut diff --git a/packages/@react-aria/slider/stories/Slider.stories.tsx b/packages/@react-aria/slider/stories/Slider.stories.tsx index 2c7184175cc..ed55875f1d7 100644 --- a/packages/@react-aria/slider/stories/Slider.stories.tsx +++ b/packages/@react-aria/slider/stories/Slider.stories.tsx @@ -6,7 +6,7 @@ import {StoryRangeSlider} from './StoryRangeSlider'; import {StorySlider} from './StorySlider'; -storiesOf('Slider', module) +storiesOf('Slider (hooks)', module) .add( 'single', () => @@ -25,11 +25,11 @@ storiesOf('Slider', module) ) .add( 'range', - () => ( ( ( ( ( - @@ -68,9 +68,9 @@ storiesOf('Slider', module) .add( '3 thumbs with disabled', () => ( - @@ -82,9 +82,9 @@ storiesOf('Slider', module) .add( '3 thumbs with aria-label', () => ( - diff --git a/packages/@react-aria/slider/stories/StoryMultiSlider.tsx b/packages/@react-aria/slider/stories/StoryMultiSlider.tsx index 2b7fbcbf818..86d0e8f1e2d 100644 --- a/packages/@react-aria/slider/stories/StoryMultiSlider.tsx +++ b/packages/@react-aria/slider/stories/StoryMultiSlider.tsx @@ -75,7 +75,6 @@ export function StoryThumb(props: StoryThumbProps) { const {inputProps, thumbProps, labelProps} = useSliderThumb({ index, ...props, - isReadOnly: sliderProps.isReadOnly || props.isReadOnly, isDisabled: sliderProps.isDisabled || props.isDisabled, trackRef: context.trackRef, inputRef diff --git a/packages/@react-aria/slider/stories/StoryRangeSlider.tsx b/packages/@react-aria/slider/stories/StoryRangeSlider.tsx index 60cb0031d59..17a4fed7457 100644 --- a/packages/@react-aria/slider/stories/StoryRangeSlider.tsx +++ b/packages/@react-aria/slider/stories/StoryRangeSlider.tsx @@ -31,7 +31,6 @@ export function StoryRangeSlider(props: StoryRangeSliderProps) { const {thumbProps: minThumbProps, inputProps: minInputProps} = useSliderThumb({ index: 0, 'aria-label': minLabel ?? 'Minimum', - isReadOnly: props.isReadOnly, isDisabled: props.isDisabled, trackRef, inputRef: minInputRef @@ -40,7 +39,6 @@ export function StoryRangeSlider(props: StoryRangeSliderProps) { const {thumbProps: maxThumbProps, inputProps: maxInputProps} = useSliderThumb({ index: 1, 'aria-label': maxLabel ?? 'Maximum', - isReadOnly: props.isReadOnly, isDisabled: props.isDisabled, trackRef, inputRef: maxInputRef @@ -58,7 +56,7 @@ export function StoryRangeSlider(props: StoryRangeSliderProps) {
{ - // We make rail and filledRail children of track. User can click on the track, the + // We make rail and filledRail children of track. User can click on the track, the // rail, or the filledRail to drag by track }
@@ -77,7 +75,7 @@ export function StoryRangeSlider(props: StoryRangeSliderProps) { 'left': `${state.getThumbPercent(0) * 100}%` }}> { - // We put thumbProps on thumbHandle, so that you cannot drag by the tip + // We put thumbProps on thumbHandle, so that you cannot drag by the tip }
{props.showTip &&
{state.getThumbValueLabel(0)}
} @@ -92,7 +90,7 @@ export function StoryRangeSlider(props: StoryRangeSliderProps) { 'left': `${state.getThumbPercent(1) * 100}%` }}> { - // For fun, we put the thumbProps on the thumb container instead of just the handle. + // For fun, we put the thumbProps on the thumb container instead of just the handle. // This means you can drag the max thumb by the tip. }
diff --git a/packages/@react-aria/slider/stories/StorySlider.tsx b/packages/@react-aria/slider/stories/StorySlider.tsx index 6a542212e0e..c3d07e4517f 100644 --- a/packages/@react-aria/slider/stories/StorySlider.tsx +++ b/packages/@react-aria/slider/stories/StorySlider.tsx @@ -35,7 +35,6 @@ export function StorySlider(props: StorySliderProps) { const {thumbProps, inputProps} = useSliderThumb({ index: 0, - isReadOnly: props.isReadOnly, isDisabled: props.isDisabled, trackRef, inputRef diff --git a/packages/@react-aria/splitview/package.json b/packages/@react-aria/splitview/package.json index 3aaf71e05ef..6cabe7fdb72 100644 --- a/packages/@react-aria/splitview/package.json +++ b/packages/@react-aria/splitview/package.json @@ -19,9 +19,10 @@ }, "dependencies": { "@babel/runtime": "^7.6.2", + "@react-aria/interactions": "^3.2.0", "@react-aria/utils": "^3.1.0", - "@react-types/shared": "^3.1.0", - "@react-stately/splitview": "^3.0.0-alpha.1" + "@react-stately/splitview": "^3.0.0-alpha.1", + "@react-types/shared": "^3.1.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1" diff --git a/packages/@react-aria/utils/src/useDrag1D.ts b/packages/@react-aria/utils/src/useDrag1D.ts index 720488f63f8..d9149a82940 100644 --- a/packages/@react-aria/utils/src/useDrag1D.ts +++ b/packages/@react-aria/utils/src/useDrag1D.ts @@ -120,8 +120,8 @@ export function useDrag1D(props: UseDrag1DProps): HTMLAttributes { switch (e.key) { case 'Left': case 'ArrowLeft': - e.preventDefault(); if (orientation === 'horizontal') { + e.preventDefault(); if (onDecrement && !reverse) { onDecrement(); } else if (onIncrement && reverse) { @@ -131,8 +131,8 @@ export function useDrag1D(props: UseDrag1DProps): HTMLAttributes { break; case 'Up': case 'ArrowUp': - e.preventDefault(); if (orientation === 'vertical') { + e.preventDefault(); if (onDecrement && !reverse) { onDecrement(); } else if (onIncrement && reverse) { @@ -142,8 +142,8 @@ export function useDrag1D(props: UseDrag1DProps): HTMLAttributes { break; case 'Right': case 'ArrowRight': - e.preventDefault(); if (orientation === 'horizontal') { + e.preventDefault(); if (onIncrement && !reverse) { onIncrement(); } else if (onDecrement && reverse) { @@ -153,8 +153,8 @@ export function useDrag1D(props: UseDrag1DProps): HTMLAttributes { break; case 'Down': case 'ArrowDown': - e.preventDefault(); if (orientation === 'vertical') { + e.preventDefault(); if (onIncrement && !reverse) { onIncrement(); } else if (onDecrement && reverse) { diff --git a/packages/@react-spectrum/slider/README.md b/packages/@react-spectrum/slider/README.md new file mode 100644 index 00000000000..03d6b6d83ba --- /dev/null +++ b/packages/@react-spectrum/slider/README.md @@ -0,0 +1,3 @@ +# @react-spectrum/slider + +This package is part of [react-spectrum](https://github.com/adobe/react-spectrum). See the repo for more details. diff --git a/packages/@react-spectrum/slider/chromatic/Slider.chromatic.tsx b/packages/@react-spectrum/slider/chromatic/Slider.chromatic.tsx new file mode 100644 index 00000000000..84c276b09a8 --- /dev/null +++ b/packages/@react-spectrum/slider/chromatic/Slider.chromatic.tsx @@ -0,0 +1,28 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import React from 'react'; +import {Slider} from '../'; +import {SpectrumSliderProps} from '@react-types/slider'; +import {storiesOf} from '@storybook/react'; + +storiesOf('Slider', module) + .add( + 'name me', + () => render({label: 'Label'}) + ); + +function render(props: SpectrumSliderProps = {}) { + return ( + + ); +} diff --git a/packages/@react-spectrum/slider/index.ts b/packages/@react-spectrum/slider/index.ts new file mode 100644 index 00000000000..1210ae1e402 --- /dev/null +++ b/packages/@react-spectrum/slider/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export * from './src'; diff --git a/packages/@react-spectrum/slider/package.json b/packages/@react-spectrum/slider/package.json new file mode 100644 index 00000000000..cf1bdfca0d0 --- /dev/null +++ b/packages/@react-spectrum/slider/package.json @@ -0,0 +1,57 @@ +{ + "name": "@react-spectrum/slider", + "version": "3.0.0-alpha.1", + "private": true, + "description": "Spectrum UI components in React", + "license": "Apache-2.0", + "main": "dist/main.js", + "module": "dist/module.js", + "types": "dist/types.d.ts", + "source": "src/index.ts", + "files": [ + "dist", + "src" + ], + "sideEffects": [ + "*.css" + ], + "targets": { + "main": { + "includeNodeModules": [ + "@adobe/spectrum-css-temp" + ] + }, + "module": { + "includeNodeModules": [ + "@adobe/spectrum-css-temp" + ] + } + }, + "repository": { + "type": "git", + "url": "https://github.com/adobe/react-spectrum" + }, + "dependencies": { + "@babel/runtime": "^7.6.2", + "@react-aria/focus": "^3.2.1", + "@react-aria/i18n": "^3.1.1", + "@react-aria/interactions": "^3.2.1", + "@react-aria/slider": "^3.0.0-alpha.1", + "@react-aria/utils": "^3.0.0-alpha.1", + "@react-aria/visually-hidden": "3.2.1", + "@react-spectrum/utils": "^3.0.0-alpha.1", + "@react-stately/slider": "^3.0.0-alpha.1", + "@react-types/shared": "^3.2.0", + "@react-types/slider": "^3.0.0-alpha.0" + }, + "devDependencies": { + "@adobe/spectrum-css-temp": "^3.0.0-alpha.1" + }, + "peerDependencies": { + "react": "^16.8.0", + "@react-spectrum/provider": "^3.0.0-rc.1" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/@react-spectrum/slider/src/RangeSlider.tsx b/packages/@react-spectrum/slider/src/RangeSlider.tsx new file mode 100644 index 00000000000..a7b59778287 --- /dev/null +++ b/packages/@react-spectrum/slider/src/RangeSlider.tsx @@ -0,0 +1,87 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {classNames} from '@react-spectrum/utils'; +import {DEFAULT_MAX_VALUE, DEFAULT_MIN_VALUE} from '@react-stately/slider'; +import {FocusableRef} from '@react-types/shared'; +import {FocusRing} from '@react-aria/focus'; +import {mergeProps} from '@react-aria/utils'; +import React from 'react'; +import {SliderBase, SliderBaseChildArguments, SliderBaseProps} from './SliderBase'; +import {SpectrumRangeSliderProps} from '@react-types/slider'; +import styles from '@adobe/spectrum-css-temp/components/slider/vars.css'; +import {useHover} from '@react-aria/interactions'; +import {useLocale} from '@react-aria/i18n'; +import {VisuallyHidden} from '@react-aria/visually-hidden'; + +function RangeSlider(props: SpectrumRangeSliderProps, ref: FocusableRef) { + let {onChange, value, defaultValue, ...otherProps} = props; + + let baseProps: Omit = { + ...otherProps, + count: 2, + value: value != null ? [value.start, value.end] : undefined, + defaultValue: defaultValue != null ? [defaultValue.start, defaultValue.end] : + // make sure that useSliderState knows we have two handles + [props.minValue ?? DEFAULT_MIN_VALUE, props.maxValue ?? DEFAULT_MAX_VALUE], + onChange(v) { + onChange?.({start: v[0], end: v[1]}); + } + }; + + let {direction} = useLocale(); + let hovers = [useHover({}), useHover({})]; + + return ( + + {({state, thumbProps, inputRefs, inputProps, ticks}: SliderBaseChildArguments) => { + let cssDirection = direction === 'rtl' ? 'right' : 'left'; + + let lowerTrack = (
); + let middleTrack = (
); + let upperTrack = (
); + + let handles = [0, 1].map(i => (
+ + + +
)); + + return (<> + {lowerTrack} + {ticks} + + {handles[0]} + + {middleTrack} + + {handles[1]} + + {upperTrack}); + }} + + ); +} + + +const _RangeSlider = React.forwardRef(RangeSlider); +export {_RangeSlider as RangeSlider}; diff --git a/packages/@react-spectrum/slider/src/Slider.tsx b/packages/@react-spectrum/slider/src/Slider.tsx new file mode 100644 index 00000000000..408b1dd5e5a --- /dev/null +++ b/packages/@react-spectrum/slider/src/Slider.tsx @@ -0,0 +1,113 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {clamp, mergeProps} from '@react-aria/utils'; +import {classNames} from '@react-spectrum/utils'; +import {FocusableRef} from '@react-types/shared'; +import {FocusRing} from '@react-aria/focus'; +import React from 'react'; +import {SliderBase, SliderBaseChildArguments, SliderBaseProps} from './SliderBase'; +import {SpectrumSliderProps} from '@react-types/slider'; +import styles from '@adobe/spectrum-css-temp/components/slider/vars.css'; +import {useHover} from '@react-aria/interactions'; +import {useLocale} from '@react-aria/i18n'; +import {VisuallyHidden} from '@react-aria/visually-hidden'; + +function Slider(props: SpectrumSliderProps, ref: FocusableRef) { + let {onChange, value, defaultValue, isFilled, fillOffset, trackGradient, ...otherProps} = props; + + let baseProps: Omit = { + ...otherProps, + count: 1, + // Normalize `value: number[]` to `value: number` + value: value != null ? [value] : undefined, + defaultValue: defaultValue != null ? [defaultValue] : undefined, + onChange(v) { + onChange?.(v[0]); + } + }; + + let {direction} = useLocale(); + let {isHovered, hoverProps} = useHover({}); + + return ( + + {({inputRefs: [inputRef], thumbProps: [thumbProps], inputProps: [inputProps], ticks, state}: SliderBaseChildArguments) => { + fillOffset = fillOffset != null ? clamp(fillOffset, state.getThumbMinValue(0), state.getThumbMaxValue(0)) : fillOffset; + + let cssDirection = direction === 'rtl' ? 'right' : 'left'; + + let lowerTrack = (
); + let upperTrack = (
); + + let handle = (
+ + + +
); + + let filledTrack = null; + if (isFilled && fillOffset != null) { + let width = state.getThumbPercent(0) - state.getValuePercent(fillOffset); + let isRightOfOffset = width > 0; + let offset = isRightOfOffset ? state.getValuePercent(fillOffset) : state.getThumbPercent(0); + filledTrack = (
); + } + return (<>{lowerTrack}{ticks} + {handle} + {upperTrack}{filledTrack}); + }} + + ); +} + +const _Slider = React.forwardRef(Slider); +export {_Slider as Slider}; diff --git a/packages/@react-spectrum/slider/src/SliderBase.tsx b/packages/@react-spectrum/slider/src/SliderBase.tsx new file mode 100644 index 00000000000..6cf9b3a126d --- /dev/null +++ b/packages/@react-spectrum/slider/src/SliderBase.tsx @@ -0,0 +1,195 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {classNames, useFocusableRef, useStyleProps} from '@react-spectrum/utils'; +import {FocusableRef} from '@react-types/shared'; +import React, {CSSProperties, HTMLAttributes, MutableRefObject, ReactNode, useRef} from 'react'; +import {SliderState, useSliderState} from '@react-stately/slider'; +import {SpectrumBarSliderBase, SpectrumSliderTicksBase} from '@react-types/slider'; +import styles from '@adobe/spectrum-css-temp/components/slider/vars.css'; +import {useNumberFormatter} from '@react-aria/i18n'; +import {useProviderProps} from '@react-spectrum/provider'; +import {useSlider, useSliderThumb} from '@react-aria/slider'; + +export interface SliderBaseChildArguments { + inputRefs: MutableRefObject[], + thumbProps: HTMLAttributes[], + inputProps: HTMLAttributes[], + ticks: ReactNode, + state: SliderState +} + +export interface SliderBaseProps extends SpectrumBarSliderBase, SpectrumSliderTicksBase { + children: (SliderBaseChildArguments) => ReactNode, + classes?: string[] | Object, + style?: CSSProperties, + count: 1 | 2 +} + +function SliderBase(props: SliderBaseProps, ref: FocusableRef) { + props = useProviderProps(props); + let { + tickCount, showTickLabels, tickLabels, isDisabled, count, + children, classes, style, + labelPosition = 'top', valueLabel, showValueLabel = !!props.label, + formatOptions, + ...otherProps + } = props; + + let {styleProps} = useStyleProps(otherProps); + + // Assumes that DEFAULT_MIN_VALUE and DEFAULT_MAX_VALUE are both positive, this value needs to be passed to useSliderState, so + // getThumbMinValue/getThumbMaxValue cannot be used here. + // `Math.abs(Math.sign(a) - Math.sign(b)) === 2` is true if the values have a different sign and neither is null. + let alwaysDisplaySign = props.minValue != null && props.maxValue != null && Math.abs(Math.sign(props.minValue) - Math.sign(props.maxValue)) === 2; + if (alwaysDisplaySign) { + if (formatOptions != null) { + if (!('signDisplay' in formatOptions)) { + formatOptions = { + ...formatOptions, + // @ts-ignore + signDisplay: 'exceptZero' + }; + } + } else { + // @ts-ignore + formatOptions = {signDisplay: 'exceptZero'}; + } + } + + let state = useSliderState({...props, formatOptions}); + let trackRef = useRef(); + let { + containerProps, + trackProps, + labelProps + } = useSlider(props, state, trackRef); + + let inputRefs = []; + let thumbProps = []; + let inputProps = []; + for (let i = 0; i < count; i++) { + // eslint-disable-next-line react-hooks/rules-of-hooks + inputRefs[i] = useRef(); + // eslint-disable-next-line react-hooks/rules-of-hooks + let v = useSliderThumb({ + index: i, + isDisabled: props.isDisabled, + trackRef, + inputRef: inputRefs[i] + }, state); + + inputProps[i] = v.inputProps; + thumbProps[i] = v.thumbProps; + } + + let domRef = useFocusableRef(ref, inputRefs[0]); + + let ticks = null; + if (tickCount > 0) { + let tickList = []; + for (let i = 0; i < tickCount; i++) { + let tickLabel = tickLabels ? tickLabels[i] : state.getFormattedValue(state.getPercentValue(i / (tickCount - 1))); + tickList.push( +
+ {showTickLabels && +
+ {tickLabel} +
+ } +
+ ); + } + ticks = (
+ {tickList} +
); + } + + let formatter = useNumberFormatter(formatOptions); + + let displayValue = valueLabel; + let maxLabelLength = undefined; + if (!displayValue) { + maxLabelLength = Math.max([...formatter.format(state.getThumbMinValue(0))].length, [...formatter.format(state.getThumbMaxValue(0))].length); + switch (state.values.length) { + case 1: + displayValue = state.getThumbValueLabel(0); + break; + case 2: + // This should really use the NumberFormat#formatRange proposal... + // https://github.com/tc39/ecma402/issues/393 + // https://github.com/tc39/proposal-intl-numberformat-v3#formatrange-ecma-402-393 + displayValue = `${state.getThumbValueLabel(0)} - ${state.getThumbValueLabel(1)}`; + + maxLabelLength = 2 + 2 * Math.max( + maxLabelLength, + [...formatter.format(state.getThumbMinValue(1))].length, [...formatter.format(state.getThumbMaxValue(1))].length + ); + break; + default: + throw new Error('Only sliders with 1 or 2 handles are supported!'); + } + } + + let labelNode = ; + let valueNode = (
+ {displayValue} +
); + + return ( +
+ {(props.label) && +
+ {props.label && labelNode} + {labelPosition === 'top' && showValueLabel && valueNode} +
+ } +
+ {children({ + inputRefs, + thumbProps, + inputProps, + ticks, + state + })} +
+ {labelPosition === 'side' && +
+ {showValueLabel && valueNode} +
+ } +
+ ); +} + +const _SliderBase = React.forwardRef(SliderBase); +export {_SliderBase as SliderBase}; diff --git a/packages/@react-spectrum/slider/src/index.ts b/packages/@react-spectrum/slider/src/index.ts new file mode 100644 index 00000000000..df218fa71cb --- /dev/null +++ b/packages/@react-spectrum/slider/src/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/// + +export * from './Slider'; +export * from './RangeSlider'; diff --git a/packages/@react-spectrum/slider/stories/RangeSlider.stories.tsx b/packages/@react-spectrum/slider/stories/RangeSlider.stories.tsx new file mode 100644 index 00000000000..151260e0686 --- /dev/null +++ b/packages/@react-spectrum/slider/stories/RangeSlider.stories.tsx @@ -0,0 +1,88 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {action} from '@storybook/addon-actions'; +import {RangeSlider} from '../'; +import React, {useState} from 'react'; +import {SpectrumRangeSliderProps} from '@react-types/slider'; +import {storiesOf} from '@storybook/react'; + +storiesOf('RangeSlider', module) + .add( + 'Default', + () => render({'aria-label': 'Label'}) + ) + .add( + 'label', + () => render({label: 'Label'}) + ) + .add( + 'isDisabled', + () => render({label: 'Label', defaultValue: {start: 30, end: 70}, isDisabled: true}) + ) + .add( + 'custom width', + () => render({label: 'Label', width: '200px'}) + ) + .add( + 'label overflow', + () => render({label: 'This is a rather long label for this narrow slider element.', maxValue: 1000, width: '100px'}) + ) + .add( + 'showValueLabel: false', + () => render({label: 'Label', showValueLabel: false}) + ) + .add( + 'formatOptions percent', + () => render({label: 'Label', minValue: 0, maxValue: 1, step: 0.01, formatOptions: {style: 'percent'}}) + ) + .add( + 'formatOptions centimeter', + // @ts-ignore TODO why is "unit" even missing? How well is it supported? + () => render({label: 'Label', maxValue: 1000, formatOptions: {style: 'unit', unit: 'centimeter'}}) + ) + .add( + 'custom valueLabel', + () => { + let [state, setState] = useState({start: 20, end: 50}); + return render({label: 'Label', value: state, onChange: setState, valueLabel: `${state.start} <-> ${state.end}`}); + } + ) + .add( + 'labelPosition: side', + () => render({label: 'Label', labelPosition: 'side'}) + ) + .add( + 'min/max', + () => render({label: 'Label', minValue: 30, maxValue: 70}) + ) + .add( + 'ticks', + () => render({label: 'Label', tickCount: 4}) + ) + .add( + 'showTickLabels: true', + () => render({label: 'Label', tickCount: 4, showTickLabels: true}) + ) + .add( + 'tickLabels', + () => render({label: 'Label', tickCount: 3, showTickLabels: true, tickLabels: ['A', 'B', 'C']}) + ); + +function render(props: SpectrumRangeSliderProps = {}) { + if (props.onChange == null) { + props.onChange = (v) => { + action('change')(v.start, v.end); + }; + } + return ; +} diff --git a/packages/@react-spectrum/slider/stories/Slider.stories.tsx b/packages/@react-spectrum/slider/stories/Slider.stories.tsx new file mode 100644 index 00000000000..c24a011cfc8 --- /dev/null +++ b/packages/@react-spectrum/slider/stories/Slider.stories.tsx @@ -0,0 +1,111 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {action} from '@storybook/addon-actions'; +import React, {useState} from 'react'; +import {Slider} from '../'; +import {SpectrumSliderProps} from '@react-types/slider'; +import {storiesOf} from '@storybook/react'; + +storiesOf('Slider', module) + .add( + 'Default', + () => render({'aria-label': 'Label'}) + ) + .add( + 'label', + () => render({label: 'Label'}) + ) + .add( + 'isDisabled', + () => render({label: 'Label', defaultValue: 50, isDisabled: true}) + ) + .add( + 'custom width', + () => render({label: 'Label', width: '200px'}) + ) + .add( + 'label overflow', + () => render({label: 'This is a rather long label for this narrow slider element.', maxValue: 1000, width: '100px'}) + ) + .add( + 'showValueLabel: false', + () => render({label: 'Label', showValueLabel: false}) + ) + .add( + 'formatOptions percent', + () => render({label: 'Label', minValue: 0, maxValue: 1, step: 0.01, formatOptions: {style: 'percent'}}) + ) + .add( + 'formatOptions centimeter', + // @ts-ignore TODO why is "unit" even missing? How well is it supported? + () => render({label: 'Label', maxValue: 1000, formatOptions: {style: 'unit', unit: 'centimeter'}}) + ) + .add( + 'custom valueLabel', + () => { + let [state, setState] = useState(0); + return render({label: 'Label', value: state, onChange: setState, valueLabel: `A ${state} B`}); + } + ) + .add( + 'labelPosition: side', + () => render({label: 'Label', labelPosition: 'side'}) + ) + .add( + 'min/max', + () => render({label: 'Label', minValue: 30, maxValue: 70}) + ) + .add( + 'isFilled: true', + () => render({label: 'Label', isFilled: true}) + ) + .add( + 'fillOffset', + () => render({label: 'Exposure', isFilled: true, fillOffset: 0, defaultValue: 0, minValue: -7, maxValue: 5}) + ) + .add( + 'ticks', + () => render({label: 'Label', tickCount: 4}) + ) + .add( + 'showTickLabels: true', + () => render({label: 'Label', tickCount: 4, showTickLabels: true}) + ) + .add( + 'showTickLabels, custom formatOptions', + // @ts-ignore + () => render({label: 'Label', tickCount: 5, showTickLabels: true, minValue: -10, maxValue: 10, width: '200px', formatOptions: {style: 'unit', unit: 'centimeter'}}) + ) + .add( + 'tickLabels', + () => render({label: 'Label', tickCount: 3, showTickLabels: true, tickLabels: ['A', 'B', 'C']}) + ) + .add( + 'trackGradient', + () => render({label: 'Label', trackGradient: ['blue', 'red']}) + ) + .add( + 'trackGradient with fillOffset', + () => render({label: 'Label', trackGradient: ['blue', 'red'], isFilled: true, fillOffset: 50}) + ) + .add( + '* orientation: vertical', + () => render({label: 'Label', orientation: 'vertical'}) + ); + +function render(props: SpectrumSliderProps = {}) { + if (props.onChange == null) { + props.onChange = action('change'); + } + return ; +} diff --git a/packages/@react-spectrum/slider/test/RangeSlider.test.tsx b/packages/@react-spectrum/slider/test/RangeSlider.test.tsx new file mode 100644 index 00000000000..62ba8416726 --- /dev/null +++ b/packages/@react-spectrum/slider/test/RangeSlider.test.tsx @@ -0,0 +1,455 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {fireEvent, render} from '@testing-library/react'; +import {press, testKeypresses} from './utils'; +import {Provider} from '@adobe/react-spectrum'; +import {RangeSlider} from '../'; +import React, {useState} from 'react'; +import {theme} from '@react-spectrum/theme-default'; +import userEvent from '@testing-library/user-event'; + + +describe('RangeSlider', function () { + it('supports aria-label', function () { + let {getByRole} = render(); + + let group = getByRole('group'); + expect(group).toHaveAttribute('aria-label', 'The Label'); + + // No label/value + expect(group.textContent).toBeFalsy(); + }); + + it('supports label', function () { + let {getAllByRole, getByRole} = render(); + + let group = getByRole('group'); + let labelId = group.getAttribute('aria-labelledby'); + let [leftSlider, rightSlider] = getAllByRole('slider'); + expect(leftSlider.getAttribute('aria-labelledby')).toBe(labelId); + expect(rightSlider.getAttribute('aria-labelledby')).toBe(labelId); + + expect(document.getElementById(labelId)).toHaveTextContent(/^The Label$/); + + // Shows value as well + expect(group.textContent).toBe('The Label0 - 100'); + }); + + it('supports showValueLabel: false', function () { + let {getByRole} = render(); + let group = getByRole('group'); + + expect(group.textContent).toBe('The Label'); + }); + + it('supports disabled', function () { + let {getAllByRole} = render(
+ + + +
); + + let [leftSlider, rightSlider] = getAllByRole('slider'); + expect(leftSlider).toBeDisabled(); + expect(rightSlider).toBeDisabled(); + let [buttonA, buttonB] = getAllByRole('button'); + + userEvent.tab(); + expect(document.activeElement).toBe(buttonA); + userEvent.tab(); + expect(document.activeElement).toBe(buttonB); + }); + + it('can be focused', function () { + let {getAllByRole} = render(
+ + + +
); + + let [sliderLeft, sliderRight] = getAllByRole('slider'); + let [buttonA, buttonB] = getAllByRole('button'); + + userEvent.tab(); + expect(document.activeElement).toBe(buttonA); + userEvent.tab(); + expect(document.activeElement).toBe(sliderLeft); + userEvent.tab(); + expect(document.activeElement).toBe(sliderRight); + userEvent.tab(); + expect(document.activeElement).toBe(buttonB); + userEvent.tab({shift: true}); + expect(document.activeElement).toBe(sliderRight); + userEvent.tab({shift: true}); + expect(document.activeElement).toBe(sliderLeft); + userEvent.tab({shift: true}); + expect(document.activeElement).toBe(buttonA); + }); + + it('supports defaultValue', function () { + let {getAllByRole} = render(); + + let [sliderLeft, sliderRight] = getAllByRole('slider'); + + expect(sliderLeft).toHaveProperty('value', '20'); + expect(sliderRight).toHaveProperty('value', '40'); + fireEvent.change(sliderLeft, {target: {value: '30'}}); + expect(sliderLeft).toHaveProperty('value', '30'); + fireEvent.change(sliderRight, {target: {value: '50'}}); + expect(sliderRight).toHaveProperty('value', '50'); + }); + + it('can be controlled', function () { + let renders = []; + + function Test() { + let [value, setValue] = useState({start: 20, end: 40}); + renders.push(value); + + return (); + } + + let {getAllByRole} = render(); + let [sliderLeft, sliderRight] = getAllByRole('slider'); + + expect(sliderLeft).toHaveProperty('value', '20'); + expect(sliderLeft).toHaveAttribute('aria-valuetext', '20'); + expect(sliderRight).toHaveProperty('value', '40'); + expect(sliderRight).toHaveAttribute('aria-valuetext', '40'); + fireEvent.change(sliderLeft, {target: {value: '30'}}); + expect(sliderLeft).toHaveProperty('value', '30'); + expect(sliderLeft).toHaveAttribute('aria-valuetext', '30'); + fireEvent.change(sliderRight, {target: {value: '50'}}); + expect(sliderRight).toHaveProperty('value', '50'); + expect(sliderRight).toHaveAttribute('aria-valuetext', '50'); + + expect(renders).toStrictEqual([{start: 20, end: 40}, {start: 30, end: 40}, {start: 30, end: 50}]); + }); + + it('supports a custom valueLabel', function () { + function Test() { + let [value, setValue] = useState({start: 10, end: 40}); + return (); + } + + let {getAllByRole, getByRole} = render(); + + let group = getByRole('group'); + let [sliderLeft, sliderRight] = getAllByRole('slider'); + + expect(group.textContent).toBe('The LabelA10B40C'); + // TODO should aria-valuetext be formatted as well? + expect(sliderLeft).toHaveAttribute('aria-valuetext', '10'); + expect(sliderRight).toHaveAttribute('aria-valuetext', '40'); + fireEvent.change(sliderLeft, {target: {value: '5'}}); + expect(sliderLeft).toHaveAttribute('aria-valuetext', '5'); + expect(group.textContent).toBe('The LabelA5B40C'); + fireEvent.change(sliderRight, {target: {value: '60'}}); + expect(group.textContent).toBe('The LabelA5B60C'); + expect(sliderRight).toHaveAttribute('aria-valuetext', '60'); + }); + + describe('formatOptions', () => { + it('prefixes the value with a plus sign if needed', function () { + let {getAllByRole, getByRole} = render( + + ); + + let group = getByRole('group'); + let [sliderLeft, sliderRight] = getAllByRole('slider'); + + expect(group.textContent).toBe('The Label+10 - +20'); + expect(sliderLeft).toHaveAttribute('aria-valuetext', '+10'); + expect(sliderRight).toHaveAttribute('aria-valuetext', '+20'); + fireEvent.change(sliderLeft, {target: {value: '-35'}}); + expect(sliderLeft).toHaveAttribute('aria-valuetext', '-35'); + expect(group.textContent).toBe('The Label-35 - +20'); + fireEvent.change(sliderRight, {target: {value: '0'}}); + expect(group.textContent).toBe('The Label-35 - 0'); + expect(sliderRight).toHaveAttribute('aria-valuetext', '0'); + }); + + it('supports setting custom formatOptions', function () { + let {getAllByRole, getByRole} = render( + + ); + + let group = getByRole('group'); + let [sliderLeft, sliderRight] = getAllByRole('slider'); + + expect(group.textContent).toBe('The Label20% - 60%'); + expect(sliderLeft).toHaveAttribute('aria-valuetext', '20%'); + expect(sliderRight).toHaveAttribute('aria-valuetext', '60%'); + fireEvent.change(sliderLeft, {target: {value: '0.3'}}); + expect(group.textContent).toBe('The Label30% - 60%'); + expect(sliderLeft).toHaveAttribute('aria-valuetext', '30%'); + fireEvent.change(sliderRight, {target: {value: '0.7'}}); + expect(group.textContent).toBe('The Label30% - 70%'); + expect(sliderRight).toHaveAttribute('aria-valuetext', '70%'); + }); + }); + + describe('keyboard interactions', () => { + // Can't test arrow/page up/down arrows because they are handled by the browser and JSDOM doesn't feel like it. + + it.each` + Name | props | commands + ${'(left/right arrows, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.ArrowRight, result: +1}, {left: press.ArrowLeft, result: -1}, {right: press.ArrowRight, result: +1}, {right: press.ArrowLeft, result: -1}]} + ${'(left/right arrows, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.ArrowRight, result: -1}, {left: press.ArrowLeft, result: +1}, {right: press.ArrowRight, result: -1}, {right: press.ArrowLeft, result: +1}]} + ${'(home/end, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.End, result: '50'}, {left: press.Home, result: '0'}, {left: press.ArrowRight, result: '1'}, {right: press.Home, result: '1'}, {right: press.End, result: '100'}]} + ${'(home/end, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.End, result: '50'}, {left: press.Home, result: '0'}, {left: press.ArrowLeft, result: '1'}, {right: press.Home, result: '1'}, {right: press.End, result: '100'}]} + ${'(left/right arrows, isDisabled)'} | ${{locale: 'de-DE', isDisabled: true}}| ${[{left: press.ArrowRight, result: 0}, {left: press.ArrowLeft, result: 0}, {right: press.ArrowRight, result: 0}, {right: press.ArrowLeft, result: 0}]} + ${'(home/end, isDisabled)'} | ${{locale: 'de-DE', isDisabled: true}}| ${[{left: press.End, result: 0}, {left: press.Home, result: 0}, {right: press.End, result: 0}, {right: press.Home, result: 0}]} + `('$Name moves the slider in the correct direction', function ({props, commands}) { + let tree = render( + + + + ); + let sliders = tree.getAllByRole('slider') as [HTMLInputElement, HTMLInputElement]; + testKeypresses(sliders, commands); + }); + + it.each` + Name | props | commands + ${'(left/right arrows, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.ArrowRight, result: +10}, {left: press.ArrowLeft, result: -10}, {right: press.ArrowRight, result: +10}, {right: press.ArrowLeft, result: -10}]} + ${'(left/right arrows, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.ArrowRight, result: -10}, {left: press.ArrowLeft, result: +10}, {right: press.ArrowRight, result: -10}, {right: press.ArrowLeft, result: +10}]} + `('$Name respects the step size', function ({props, commands}) { + let tree = render( + + + + ); + let sliders = tree.getAllByRole('slider') as [HTMLInputElement, HTMLInputElement]; + testKeypresses(sliders, commands); + }); + + it.each` + Name | props | commands + ${'(left/right arrows, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.ArrowLeft, result: -1}, {left: press.ArrowLeft, result: 0}, {right: press.ArrowRight, result: +1}, {right: press.ArrowRight, result: 0}]} + ${'(left/right arrows, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.ArrowRight, result: -1}, {left: press.ArrowRight, result: 0}, {right: press.ArrowLeft, result: +1}, {right: press.ArrowLeft, result: 0}]} + `('$Name is clamped by min/max', function ({props, commands}) { + let tree = render( + + + + ); + let sliders = tree.getAllByRole('slider') as [HTMLInputElement, HTMLInputElement]; + testKeypresses(sliders, commands); + }); + }); + + describe('mouse interactions', () => { + beforeAll(() => { + jest.spyOn(window.HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(() => 100); + }); + afterAll(() => { + // @ts-ignore + window.HTMLElement.prototype.offsetWidth.mockReset(); + }); + + it('can click and drag handle', () => { + let onChangeSpy = jest.fn(); + let {getAllByRole} = render( + + ); + + let [sliderLeft, sliderRight] = getAllByRole('slider'); + let [thumbLeft, thumbRight] = [sliderLeft.parentElement.parentElement, sliderRight.parentElement.parentElement]; + + fireEvent.mouseDown(thumbLeft, {clientX: 20}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(sliderLeft); + fireEvent.mouseMove(thumbLeft, {clientX: 10}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy).toHaveBeenLastCalledWith({start: 10, end: 50}); + fireEvent.mouseMove(thumbLeft, {clientX: -10}); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + expect(onChangeSpy).toHaveBeenLastCalledWith({start: 0, end: 50}); + fireEvent.mouseMove(thumbLeft, {clientX: 120}); + expect(onChangeSpy).toHaveBeenCalledTimes(3); + expect(onChangeSpy).toHaveBeenLastCalledWith({start: 50, end: 50}); + fireEvent.mouseUp(thumbLeft, {clientX: 120}); + expect(onChangeSpy).toHaveBeenCalledTimes(3); + + onChangeSpy.mockClear(); + + fireEvent.mouseDown(thumbRight, {clientX: 50}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(sliderRight); + fireEvent.mouseMove(thumbRight, {clientX: 60}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy).toHaveBeenLastCalledWith({start: 50, end: 60}); + fireEvent.mouseMove(thumbRight, {clientX: -10}); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + expect(onChangeSpy).toHaveBeenLastCalledWith({start: 50, end: 50}); + fireEvent.mouseMove(thumbRight, {clientX: 120}); + expect(onChangeSpy).toHaveBeenCalledTimes(3); + expect(onChangeSpy).toHaveBeenLastCalledWith({start: 50, end: 100}); + fireEvent.mouseUp(thumbRight, {clientX: 120}); + expect(onChangeSpy).toHaveBeenCalledTimes(3); + }); + + it('cannot click and drag handle when disabled', () => { + let onChangeSpy = jest.fn(); + let {getAllByRole} = render( + + ); + + let [sliderLeft, sliderRight] = getAllByRole('slider'); + let [thumbLeft, thumbRight] = [sliderLeft.parentElement.parentElement, sliderRight.parentElement.parentElement]; + + fireEvent.mouseDown(thumbLeft, {clientX: 20}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(document.activeElement).not.toBe(sliderLeft); + fireEvent.mouseMove(thumbLeft, {clientX: 10}); + expect(onChangeSpy).not.toHaveBeenCalled(); + fireEvent.mouseMove(thumbLeft, {clientX: -10}); + expect(onChangeSpy).not.toHaveBeenCalled(); + fireEvent.mouseMove(thumbLeft, {clientX: 120}); + expect(onChangeSpy).not.toHaveBeenCalled(); + fireEvent.mouseUp(thumbLeft, {clientX: 120}); + expect(onChangeSpy).not.toHaveBeenCalled(); + + onChangeSpy.mockClear(); + + fireEvent.mouseDown(thumbRight, {clientX: 50}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(document.activeElement).not.toBe(sliderRight); + fireEvent.mouseMove(thumbRight, {clientX: 60}); + expect(onChangeSpy).not.toHaveBeenCalled(); + fireEvent.mouseMove(thumbRight, {clientX: -10}); + expect(onChangeSpy).not.toHaveBeenCalled(); + fireEvent.mouseMove(thumbRight, {clientX: 120}); + expect(onChangeSpy).not.toHaveBeenCalled(); + fireEvent.mouseUp(thumbRight, {clientX: 120}); + expect(onChangeSpy).not.toHaveBeenCalled(); + }); + + it('can click on track to move nearest handle', () => { + let onChangeSpy = jest.fn(); + let {getAllByRole} = render( + + ); + + let [sliderLeft, sliderRight] = getAllByRole('slider'); + let [thumbLeft, thumbRight] = [sliderLeft.parentElement.parentElement, sliderRight.parentElement.parentElement]; + + // @ts-ignore + let [leftTrack, middleTrack, rightTrack] = [...thumbLeft.parentElement.children].filter(c => c !== thumbLeft && c !== thumbRight); + + // left track + fireEvent.mouseDown(leftTrack, {clientX: 20}); + expect(document.activeElement).toBe(sliderLeft); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy).toHaveBeenLastCalledWith({start: 20, end: 70}); + fireEvent.mouseUp(thumbLeft, {clientX: 20}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + + // middle track, near left slider + onChangeSpy.mockClear(); + fireEvent.mouseDown(middleTrack, {clientX: 40}); + expect(document.activeElement).toBe(sliderLeft); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy).toHaveBeenLastCalledWith({start: 40, end: 70}); + fireEvent.mouseUp(thumbLeft, {clientX: 40}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + + // middle track, near right slider + onChangeSpy.mockClear(); + fireEvent.mouseDown(middleTrack, {clientX: 60}); + expect(document.activeElement).toBe(sliderRight); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy).toHaveBeenLastCalledWith({start: 40, end: 60}); + fireEvent.mouseUp(thumbRight, {clientX: 60}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + + // right track + onChangeSpy.mockClear(); + fireEvent.mouseDown(rightTrack, {clientX: 90}); + expect(document.activeElement).toBe(sliderRight); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy).toHaveBeenLastCalledWith({start: 40, end: 90}); + fireEvent.mouseUp(thumbRight, {clientX: 90}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + }); + + it('cannot click on track to move nearest handle when disabled', () => { + let onChangeSpy = jest.fn(); + let {getAllByRole} = render( + + ); + + let [sliderLeft, sliderRight] = getAllByRole('slider'); + let [thumbLeft, thumbRight] = [sliderLeft.parentElement.parentElement, sliderRight.parentElement.parentElement]; + + // @ts-ignore + let [leftTrack, middleTrack, rightTrack] = [...thumbLeft.parentElement.children].filter(c => c !== thumbLeft && c !== thumbRight); + + // left track + fireEvent.mouseDown(leftTrack, {clientX: 20}); + expect(document.activeElement).not.toBe(sliderLeft); + expect(onChangeSpy).not.toHaveBeenCalled(); + fireEvent.mouseUp(thumbLeft, {clientX: 20}); + expect(onChangeSpy).not.toHaveBeenCalled(); + + // middle track, near left slider + onChangeSpy.mockClear(); + fireEvent.mouseDown(middleTrack, {clientX: 40}); + expect(document.activeElement).not.toBe(sliderLeft); + expect(onChangeSpy).not.toHaveBeenCalled(); + fireEvent.mouseUp(thumbLeft, {clientX: 40}); + expect(onChangeSpy).not.toHaveBeenCalled(); + + // middle track, near right slider + onChangeSpy.mockClear(); + fireEvent.mouseDown(middleTrack, {clientX: 60}); + expect(document.activeElement).not.toBe(sliderRight); + expect(onChangeSpy).not.toHaveBeenCalled(); + fireEvent.mouseUp(thumbRight, {clientX: 60}); + expect(onChangeSpy).not.toHaveBeenCalled(); + + // right track + onChangeSpy.mockClear(); + fireEvent.mouseDown(rightTrack, {clientX: 90}); + expect(document.activeElement).not.toBe(sliderRight); + expect(onChangeSpy).not.toHaveBeenCalled(); + fireEvent.mouseUp(thumbRight, {clientX: 90}); + expect(onChangeSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/@react-spectrum/slider/test/Slider.test.tsx b/packages/@react-spectrum/slider/test/Slider.test.tsx new file mode 100644 index 00000000000..130ba7f9780 --- /dev/null +++ b/packages/@react-spectrum/slider/test/Slider.test.tsx @@ -0,0 +1,364 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {act, fireEvent, render} from '@testing-library/react'; +import {press, testKeypresses} from './utils'; +import {Provider} from '@adobe/react-spectrum'; +import React, {useState} from 'react'; +import {Slider} from '../'; +import {theme} from '@react-spectrum/theme-default'; +import userEvent from '@testing-library/user-event'; + +describe('Slider', function () { + it('supports aria-label', function () { + let {getByRole} = render(); + + let group = getByRole('group'); + expect(group).toHaveAttribute('aria-label', 'The Label'); + + // No label/value + expect(group.textContent).toBeFalsy(); + + let slider = getByRole('slider'); + expect(slider).toHaveAttribute('aria-valuetext', '0'); + }); + + it('supports label', function () { + let {getByRole} = render(); + + let group = getByRole('group'); + let labelId = group.getAttribute('aria-labelledby'); + let slider = getByRole('slider'); + expect(slider.getAttribute('aria-labelledby')).toBe(labelId); + + expect(document.getElementById(labelId)).toHaveTextContent(/^The Label$/); + + // Shows value as well + expect(group.textContent).toBe('The Label0'); + expect(slider).toHaveAttribute('aria-valuetext', '0'); + }); + + it('supports showValueLabel: false', function () { + let {getByRole} = render(); + let group = getByRole('group'); + expect(group.textContent).toBe('The Label'); + + let slider = getByRole('slider'); + expect(slider).toHaveAttribute('aria-valuetext', '0'); + }); + + it('supports disabled', function () { + let {getByRole, getAllByRole} = render(
+ + + +
); + + let slider = getByRole('slider'); + let [buttonA, buttonB] = getAllByRole('button'); + expect(slider).toBeDisabled(); + + userEvent.tab(); + expect(document.activeElement).toBe(buttonA); + userEvent.tab(); + expect(document.activeElement).toBe(buttonB); + }); + + it('can be focused', function () { + let {getByRole, getAllByRole} = render(
+ + + +
); + + let slider = getByRole('slider'); + let [buttonA, buttonB] = getAllByRole('button'); + act(() => { + slider.focus(); + }); + + expect(document.activeElement).toBe(slider); + userEvent.tab(); + expect(document.activeElement).toBe(buttonB); + userEvent.tab({shift: true}); + userEvent.tab({shift: true}); + expect(document.activeElement).toBe(buttonA); + }); + + it('supports defaultValue', function () { + let {getByRole} = render(); + + let slider = getByRole('slider'); + + expect(slider).toHaveProperty('value', '20'); + expect(slider).toHaveAttribute('aria-valuetext', '20'); + fireEvent.change(slider, {target: {value: '40'}}); + expect(slider).toHaveProperty('value', '40'); + expect(slider).toHaveAttribute('aria-valuetext', '40'); + }); + + it('can be controlled', function () { + let renders = []; + + function Test() { + let [value, setValue] = useState(50); + renders.push(value); + + return (); + } + + let {getByRole} = render(); + + let slider = getByRole('slider'); + + expect(slider).toHaveProperty('value', '50'); + expect(slider).toHaveAttribute('aria-valuetext', '50'); + fireEvent.change(slider, {target: {value: '55'}}); + expect(slider).toHaveProperty('value', '55'); + expect(slider).toHaveAttribute('aria-valuetext', '55'); + + expect(renders).toStrictEqual([50, 55]); + }); + + it('supports a custom valueLabel', function () { + function Test() { + let [value, setValue] = useState(50); + return (); + } + + let {getByRole} = render(); + + let group = getByRole('group'); + let slider = getByRole('slider'); + + expect(group.textContent).toBe('The LabelA50B'); + // TODO should aria-valuetext be formatted as well? + expect(slider).toHaveAttribute('aria-valuetext', '50'); + fireEvent.change(slider, {target: {value: '55'}}); + expect(group.textContent).toBe('The LabelA55B'); + expect(slider).toHaveAttribute('aria-valuetext', '55'); + }); + + describe('formatOptions', () => { + it('prefixes the value with a plus sign if needed', function () { + let {getByRole} = render( + + ); + + let group = getByRole('group'); + let slider = getByRole('slider'); + + expect(group.textContent).toBe('The Label+10'); + expect(slider).toHaveAttribute('aria-valuetext', '+10'); + fireEvent.change(slider, {target: {value: '0'}}); + expect(group.textContent).toBe('The Label0'); + expect(slider).toHaveAttribute('aria-valuetext', '0'); + }); + + it('supports setting custom formatOptions', function () { + let {getByRole} = render( + + ); + + let group = getByRole('group'); + let slider = getByRole('slider'); + + expect(group.textContent).toBe('The Label20%'); + expect(slider).toHaveAttribute('aria-valuetext', '20%'); + fireEvent.change(slider, {target: {value: 0.5}}); + expect(group.textContent).toBe('The Label50%'); + expect(slider).toHaveAttribute('aria-valuetext', '50%'); + }); + }); + + describe('keyboard interactions', () => { + // Can't test arrow/page up/down arrows because they are handled by the browser and JSDOM doesn't feel like it. + + it.each` + Name | props | commands + ${'(left/right arrows, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.ArrowRight, result: +1}, {left: press.ArrowLeft, result: -1}]} + ${'(left/right arrows, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.ArrowRight, result: -1}, {left: press.ArrowLeft, result: +1}]} + ${'(home/end, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.End, result: '100'}, {left: press.Home, result: '0'}]} + ${'(home/end, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.End, result: '100'}, {left: press.Home, result: '0'}]} + ${'(left/right arrows, isDisabled)'} | ${{locale: 'de-DE', isDisabled: true}}| ${[{left: press.ArrowRight, result: 0}, {left: press.ArrowLeft, result: 0}]} + ${'(home/end, isDisabled)'} | ${{locale: 'de-DE', isDisabled: true}}| ${[{left: press.End, result: 0}, {left: press.Home, result: 0}]} + `('$Name moves the slider in the correct direction', function ({props, commands}) { + let tree = render( + + + + ); + let slider = tree.getByRole('slider'); + testKeypresses([slider, slider], commands); + }); + + it.each` + Name | props | commands + ${'(left/right arrows, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.ArrowRight, result: +10}, {left: press.ArrowLeft, result: -10}]} + ${'(left/right arrows, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.ArrowRight, result: -10}, {left: press.ArrowLeft, result: +10}]} + `('$Name respects the step size', function ({props, commands}) { + let tree = render( + + + + ); + let slider = tree.getByRole('slider'); + testKeypresses([slider, slider], commands); + }); + + it.each` + Name | props | commands + ${'(left/right arrows, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.ArrowLeft, result: -1}, {left: press.ArrowLeft, result: 0}]} + ${'(left/right arrows, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.ArrowRight, result: -1}, {left: press.ArrowRight, result: 0}]} + `('$Name is clamped by min/max', function ({props, commands}) { + let tree = render( + + + + ); + let slider = tree.getByRole('slider'); + testKeypresses([slider, slider], commands); + }); + }); + + describe('mouse interactions', () => { + beforeAll(() => { + jest.spyOn(window.HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(() => 100); + }); + afterAll(() => { + // @ts-ignore + window.HTMLElement.prototype.offsetWidth.mockReset(); + }); + + it('can click and drag handle', () => { + let onChangeSpy = jest.fn(); + let {getByRole} = render( + + ); + + let slider = getByRole('slider'); + let thumb = slider.parentElement; + fireEvent.mouseDown(thumb, {clientX: 50}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(slider); + + fireEvent.mouseMove(thumb, {clientX: 10}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy).toHaveBeenLastCalledWith(10); + fireEvent.mouseMove(thumb, {clientX: -10}); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + expect(onChangeSpy).toHaveBeenLastCalledWith(0); + fireEvent.mouseMove(thumb, {clientX: 120}); + expect(onChangeSpy).toHaveBeenCalledTimes(3); + expect(onChangeSpy).toHaveBeenLastCalledWith(100); + fireEvent.mouseUp(thumb, {clientX: 120}); + expect(onChangeSpy).toHaveBeenCalledTimes(3); + }); + + it('cannot click and drag handle when disabled', () => { + let onChangeSpy = jest.fn(); + let {getByRole} = render( + + ); + + let slider = getByRole('slider'); + let thumb = slider.parentElement; + fireEvent.mouseDown(thumb, {clientX: 50}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(document.activeElement).not.toBe(slider); + fireEvent.mouseMove(thumb, {clientX: 10}); + expect(onChangeSpy).not.toHaveBeenCalled(); + fireEvent.mouseUp(thumb, {clientX: 10}); + expect(onChangeSpy).not.toHaveBeenCalled(); + }); + + it('can click on track to move handle', () => { + let onChangeSpy = jest.fn(); + let {getByRole} = render( + + ); + + let slider = getByRole('slider'); + let thumb = slider.parentElement.parentElement; + // @ts-ignore + let [leftTrack, rightTrack] = [...thumb.parentElement.children].filter(c => c !== thumb); + + // left track + fireEvent.mouseDown(leftTrack, {clientX: 20}); + expect(document.activeElement).toBe(slider); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy).toHaveBeenLastCalledWith(20); + fireEvent.mouseUp(thumb, {clientX: 20}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + + // right track + onChangeSpy.mockClear(); + fireEvent.mouseDown(rightTrack, {clientX: 70}); + expect(document.activeElement).toBe(slider); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy).toHaveBeenLastCalledWith(70); + fireEvent.mouseUp(thumb, {clientX: 70}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + }); + + it('cannot click on track to move handle when disabled', () => { + let onChangeSpy = jest.fn(); + let {getByRole} = render( + + ); + + let slider = getByRole('slider'); + let thumb = slider.parentElement.parentElement; + // @ts-ignore + let [leftTrack, rightTrack] = [...thumb.parentElement.children].filter(c => c !== thumb); + + // left track + fireEvent.mouseDown(leftTrack, {clientX: 20}); + expect(document.activeElement).not.toBe(slider); + expect(onChangeSpy).not.toHaveBeenCalled(); + fireEvent.mouseUp(thumb, {clientX: 20}); + expect(onChangeSpy).not.toHaveBeenCalled(); + + // right track + onChangeSpy.mockClear(); + fireEvent.mouseDown(rightTrack, {clientX: 70}); + expect(document.activeElement).not.toBe(slider); + expect(onChangeSpy).not.toHaveBeenCalled(); + fireEvent.mouseUp(thumb, {clientX: 70}); + expect(onChangeSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/@react-spectrum/slider/test/utils.ts b/packages/@react-spectrum/slider/test/utils.ts new file mode 100644 index 00000000000..6558eb0fb81 --- /dev/null +++ b/packages/@react-spectrum/slider/test/utils.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {act, fireEvent} from '@testing-library/react'; + +function pressKeyOnButton(key, button) { + act(() => {fireEvent.keyDown(button, {key});}); +} +export const press = { + ArrowRight: (button: HTMLElement) => pressKeyOnButton('ArrowRight', button), + ArrowLeft: (button: HTMLElement) => pressKeyOnButton('ArrowLeft', button), + Home: (button: HTMLElement) => pressKeyOnButton('Home', button), + End: (button: HTMLElement) => pressKeyOnButton('End', button) +}; + +export function testKeypresses([sliderLeft, sliderRight], commands: any[]) { + for (let command of commands) { + let c = command.left ?? command.right; + let result = command.result; + let slider = command.left ? sliderLeft : sliderRight; + let oldValue = Number(slider.value); + act(() => {slider.focus();}); + c(slider); + + if (typeof result === 'string') { + // absolute + expect(slider).toHaveProperty('value', result); + } else { + // number, relative + expect(slider).toHaveProperty('value', String(oldValue + result)); + } + } +} diff --git a/packages/@react-stately/slider/src/useSliderState.ts b/packages/@react-stately/slider/src/useSliderState.ts index f1350a21be2..deef23e5510 100644 --- a/packages/@react-stately/slider/src/useSliderState.ts +++ b/packages/@react-stately/slider/src/useSliderState.ts @@ -64,7 +64,7 @@ export const DEFAULT_MAX_VALUE = 100; export const DEFAULT_STEP_VALUE = 1; export function useSliderState(props: SliderProps): SliderState { - let {isReadOnly, isDisabled, minValue = DEFAULT_MIN_VALUE, maxValue = DEFAULT_MAX_VALUE, formatOptions, step = DEFAULT_STEP_VALUE} = props; + let {isDisabled, minValue = DEFAULT_MIN_VALUE, maxValue = DEFAULT_MAX_VALUE, formatOptions, step = DEFAULT_STEP_VALUE} = props; const [values, setValues] = useControlledState( props.value as any, @@ -97,7 +97,7 @@ export function useSliderState(props: SliderProps): SliderState { } function updateValue(index: number, value: number) { - if (isReadOnly || isDisabled || !isThumbEditable(index)) { + if (isDisabled || !isThumbEditable(index)) { return; } const thisMin = getThumbMinValue(index); @@ -109,7 +109,7 @@ export function useSliderState(props: SliderProps): SliderState { } function updateDragging(index: number, dragging: boolean) { - if (isReadOnly || isDisabled || !isThumbEditable(index)) { + if (isDisabled || !isThumbEditable(index)) { return; } diff --git a/packages/@react-types/slider/src/index.d.ts b/packages/@react-types/slider/src/index.d.ts index 07062d8f5b7..ff724c5c6da 100644 --- a/packages/@react-types/slider/src/index.d.ts +++ b/packages/@react-types/slider/src/index.d.ts @@ -1,7 +1,7 @@ -import {AriaLabelingProps, AriaValidationProps, FocusableDOMProps, FocusableProps, LabelableProps, RangeInputBase, Validation, ValueBase} from '@react-types/shared'; +import {AriaLabelingProps, AriaValidationProps, FocusableDOMProps, FocusableProps, LabelableProps, LabelPosition, Orientation, RangeInputBase, RangeValue, StyleProps, Validation, ValueBase} from '@react-types/shared'; +import {ReactNode} from 'react'; export interface BaseSliderProps extends RangeInputBase, LabelableProps, AriaLabelingProps { - isReadOnly?: boolean, isDisabled?: boolean, formatOptions?: Intl.NumberFormatOptions } @@ -11,7 +11,50 @@ export interface SliderProps extends BaseSliderProps, ValueBase { } export interface SliderThumbProps extends AriaLabelingProps, FocusableDOMProps, FocusableProps, Validation, AriaValidationProps, LabelableProps { - isReadOnly?: boolean, isDisabled?: boolean, index: number } + +export interface SpectrumBarSliderBase extends BaseSliderProps, ValueBase, StyleProps { + orientation?: Orientation, + labelPosition?: LabelPosition, + /** Whether the value's label is displayed. True by default if there's a `label`, false by default if not. */ + showValueLabel?: boolean, + /** The content to display as the value's label. Overrides default formatted number. */ + valueLabel?: ReactNode +} + +export interface SpectrumSliderTicksBase { + /** Enables tick marks if > 0. Ticks will be evenly distributed between the min and max values. */ + tickCount?: number, + + /** Enables tick labels. */ + showTickLabels?: boolean, + /** + * By default, labels are formatted using the slider's number formatter, + * but you can use the tickLabels prop to override these with custom labels. + */ + tickLabels?: Array +} + +export interface SpectrumSliderProps extends SpectrumBarSliderBase, SpectrumSliderTicksBase { + /** + * Whether a fill color is shown between the start of the slider and the current value. + * @see https://spectrum.adobe.com/page/slider/#Fill. + */ + isFilled?: boolean, + /** + * The offset from which to start the fill. + * @see https://spectrum.adobe.com/page/slider/#Fill-start. + */ + fillOffset?: number, + /** + * The background of the track, specified as the stops for a CSS background: `linear-gradient(to right/left, ...trackGradient)`. + * @example trackGradient={['red', 'green']} + * @example trackGradient={['red 20%', 'green 40%']} + * @see https://spectrum.adobe.com/page/slider/#Gradient. + */ + trackGradient?: string[] +} + +export interface SpectrumRangeSliderProps extends SpectrumBarSliderBase>, SpectrumSliderTicksBase { } diff --git a/yarn.lock b/yarn.lock index a97da377e0e..05a34541f7d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4519,13 +4519,13 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@16.9.23": - version "16.9.23" - resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.23.tgz#1a66c6d468ba11a8943ad958a8cb3e737568271c" - integrity sha512-SsGVT4E7L2wLN3tPYLiF20hmZTPGuzaayVunfgXzUn1x4uHVsKH6QDJQ/TdpHqwsTLd4CwrmQ2vOgxN7gE24gw== +"@types/react@*", "@types/react@^16.9.23": + version "16.9.49" + resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.49.tgz#09db021cf8089aba0cdb12a49f8021a69cce4872" + integrity sha512-DtLFjSj0OYAdVLBbyjhuV9CdGVHCkHn2R+xr3XkBvK2rS1Y1tkc14XSGjYgm5Fjjr90AxH9tiSzc1pCFMGO06g== dependencies: "@types/prop-types" "*" - csstype "^2.2.0" + csstype "^3.0.2" "@types/stack-utils@^1.0.1": version "1.0.1" @@ -8340,11 +8340,16 @@ cssstyle@^2.2.0: dependencies: cssom "~0.3.6" -csstype@^2.2.0, csstype@^2.5.7: +csstype@^2.5.7: version "2.6.9" resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.9.tgz#05141d0cd557a56b8891394c1911c40c8a98d098" integrity sha512-xz39Sb4+OaTsULgUERcCk+TJj8ylkL4aSVDQiX/ksxbELSqwkgt4d4RD7fovIdgJGSuNYqwZEiVjYY5l0ask+Q== +csstype@^3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.3.tgz#2b410bbeba38ba9633353aff34b05d9755d065f8" + integrity sha512-jPl+wbWPOWJ7SXsWyqGRk3lGecbar0Cb0OvZF/r/ZU011R4YqiRehgkQ9p4eQfo9DSDLqLL3wHwfxeJiuIsNag== + currently-unhandled@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea"