diff --git a/content/patterns/radio/examples/radio-rating.html b/content/patterns/radio/examples/radio-rating.html index 6be2fc6161..05d94eced9 100644 --- a/content/patterns/radio/examples/radio-rating.html +++ b/content/patterns/radio/examples/radio-rating.html @@ -36,7 +36,7 @@

About This Example

Similar examples include:

diff --git a/content/patterns/slider/examples/css/slider-rating.css b/content/patterns/slider/examples/css/slider-rating.css index a5474d5bdd..0dc3c3fc00 100644 --- a/content/patterns/slider/examples/css/slider-rating.css +++ b/content/patterns/slider/examples/css/slider-rating.css @@ -6,6 +6,7 @@ .rating-slider { color: #005a9c; + user-select: none; } .rating-slider svg { @@ -14,84 +15,88 @@ } .rating-slider svg .focus-ring { - fill: #eee; + fill: currentcolor; stroke-width: 0; fill-opacity: 0; } -.rating-slider svg .star { +.rating-slider svg .target { stroke-width: 2px; stroke: currentcolor; fill-opacity: 0; } -.rating-slider svg .fill-left, -.rating-slider svg .fill-right { - stroke-width: 0; - fill-opacity: 0; -} - -.rating-slider[aria-valuenow="5"] svg .star { +.rating-slider svg .label { + font-size: 90%; + font-family: sans-serif; fill: currentcolor; - fill-opacity: 1; } -.rating-slider[aria-valuenow="0.5"] svg .star-1 .fill-left { - fill: currentcolor; - fill-opacity: 1; +.rating-slider svg .description { + font-size: 90%; + fill: canvastext; } -.rating-slider[aria-valuenow="1"] svg .star-1 .star { +.rating-slider svg .current .target { fill: currentcolor; fill-opacity: 1; } -.rating-slider[aria-valuenow="1.5"] svg .star-1 .star, -.rating-slider[aria-valuenow="1.5"] svg .star-2 .fill-left { - fill: currentcolor; - fill-opacity: 1; +.rating-slider svg .current .label { + fill: white; + font-weight: bold; } -.rating-slider[aria-valuenow="2"] svg .star-2 .star { - fill: currentcolor; - fill-opacity: 1; -} +/* focus styling */ -.rating-slider[aria-valuenow="2.5"] svg .star-2 .star, -.rating-slider[aria-valuenow="2.5"] svg .star-3 .fill-left { - fill: currentcolor; - fill-opacity: 1; +.rating-slider:focus, +.rating-slider:focus-visible { + outline: none !important; } -.rating-slider[aria-valuenow="3"] svg .star-3 .star { - fill: currentcolor; - fill-opacity: 1; +.rating-slider svg .focus { + stroke-width: 0; + stroke: currentcolor; + fill-opacity: 0; } -.rating-slider[aria-valuenow="3.5"] svg .star-3 .star, -.rating-slider[aria-valuenow="3.5"] svg .star-4 .fill-left { - fill: currentcolor; - fill-opacity: 1; +.rating-slider:focus svg .focus-ring { + stroke-width: 2px; + stroke: currentcolor; } -.rating-slider[aria-valuenow="4"] svg .star-4 .star { - fill: currentcolor; - fill-opacity: 1; -} +@media (forced-colors: active) { + .rating-slider svg .focus-ring { + fill: linktext; + } -.rating-slider[aria-valuenow="4.5"] svg .star-4 .star, -.rating-slider[aria-valuenow="4.5"] svg .star-5 .fill-left { - fill: currentcolor; - fill-opacity: 1; -} + .rating-slider svg .target { + stroke: linktext; + } -/* focus styling */ + .rating-slider svg .label { + fill: linktext; + } -.rating-slider:focus { - outline: none; -} + .rating-slider svg .description { + fill: linktext; + } -.rating-slider:focus svg .focus-ring { - stroke-width: 2px; - stroke: currentcolor; + .rating-slider svg .current .target { + fill: linktext; + } + + .rating-slider svg .current .label { + fill: canvas; + } + + /* focus styling */ + + .rating-slider svg .focus { + stroke: linktext; + } + + .rating-slider:focus svg .focus-ring { + stroke: linktext; + } } diff --git a/content/patterns/slider/examples/js/slider-rating.js b/content/patterns/slider/examples/js/slider-rating.js index 5896160310..4eea74a725 100644 --- a/content/patterns/slider/examples/js/slider-rating.js +++ b/content/patterns/slider/examples/js/slider-rating.js @@ -8,6 +8,11 @@ * Desc: RatingSlider widget that implements ARIA Authoring Practices */ +const SELECTED_SIZE = 6; +const RAIL_LEFT = 13; +const RAIL_TOP = 35; +const RAIL_HEIGHT = 24; + class RatingSlider { constructor(domNode) { this.sliderNode = domNode; @@ -15,47 +20,49 @@ class RatingSlider { this.isMoving = false; this.svgNode = domNode.querySelector('svg'); + this.focusRect = domNode.querySelector('.focus-ring'); - // Inherit system text colors - // var color = getComputedStyle(this.sliderNode).color; - // this.svgNode.setAttribute('color', color); + this.targetRects = Array.from( + domNode.querySelectorAll('g.rating rect.target') + ); - this.starsWidth = 198; - this.starsX = 0; + this.labelTexts = Array.from( + domNode.querySelectorAll('g.rating text.label') + ); - this.svgPoint = this.svgNode.createSVGPoint(); + [this.targetInfo, this.railWidth] = this.calcRatingRects(); + this.infoDefaultFocusRect = this.calcDefaultFocusRect(); - // define possible slider positions + this.valueMin = this.getValueMin(); + this.valueMax = this.getValueMax(); this.sliderNode.addEventListener( 'keydown', this.onSliderKeydown.bind(this) ); - this.svgNode.addEventListener('click', this.onRailClick.bind(this)); - this.svgNode.addEventListener( - 'pointerdown', - this.onSliderPointerDown.bind(this) - ); + this.labelTexts.forEach((lt) => { + lt.addEventListener('click', this.onTargetClick.bind(this)); + }); - // bind a pointermove event handler to move pointer - this.svgNode.addEventListener('pointermove', this.onPointerMove.bind(this)); + this.targetRects.forEach((tr) => { + tr.addEventListener('click', this.onTargetClick.bind(this)); + tr.addEventListener('pointerdown', this.onSliderPointerDown.bind(this)); + tr.addEventListener('pointermove', this.onPointerMove.bind(this)); + }); // bind a pointerup event handler to stop tracking pointer movements document.addEventListener('pointerup', this.onPointerUp.bind(this)); - this.addTotalStarsToRatingLabel(); + this.addTotalRectsToRatingLabel(); this.sliderNode.addEventListener( 'blur', - this.addTotalStarsToRatingLabel.bind(this) + this.addTotalRectsToRatingLabel.bind(this) ); - } - // Get point in global SVG space - getSVGPoint(event) { - this.svgPoint.x = event.clientX; - this.svgPoint.y = event.clientY; - return this.svgPoint.matrixTransform(this.svgNode.getScreenCTM().inverse()); + window.addEventListener('resize', this.onResize.bind(this)); + + this.setFocusRing(0); } getValue() { @@ -70,46 +77,40 @@ class RatingSlider { return parseFloat(this.sliderNode.getAttribute('aria-valuemax')); } - isInRange(value) { - let valueMin = this.getValueMin(); - let valueMax = this.getValueMax(); - return value <= valueMax && value >= valueMin; - } - getValueText(value) { switch (value) { case 0: - return 'zero stars'; + return 'Choose a rating from one to ten where 10 is extremely satisfied'; - case 0.5: - return 'one half star'; + case 1: + return 'one, extremely dissatisfied'; - case 1.0: - return 'one star'; + case 2: + return 'two'; - case 1.5: - return 'one and a half stars'; + case 3: + return 'three'; - case 2.0: - return 'two stars'; + case 4: + return 'four'; - case 2.5: - return 'two and a half stars'; + case 5: + return 'five'; - case 3.0: - return 'three stars'; + case 6: + return 'six'; - case 3.5: - return 'three and a half stars'; + case 7: + return 'seven'; - case 4.0: - return 'four stars'; + case 8: + return 'eight'; - case 4.5: - return 'four and a half stars'; + case 9: + return 'nine'; - case 5.0: - return 'five stars'; + case 10: + return 'ten, extremely satisfied'; default: break; @@ -121,37 +122,37 @@ class RatingSlider { getValueTextWithMax(value) { switch (value) { case 0: - return 'zero of five stars'; + return 'Choose a rating from one to ten where 10 is extremely satisfied'; - case 0.5: - return 'one half of five stars'; + case 1: + return 'one out of 10, extremely dissatisfied'; - case 1.0: - return 'one of five stars'; + case 2: + return 'two out of ten where ten is extremely satisfied'; - case 1.5: - return 'one and a half of five stars'; + case 3: + return 'three out of ten where ten is extremely satisfied'; - case 2.0: - return 'two of five stars'; + case 4: + return 'four out of ten where ten is extremely satisfied'; - case 2.5: - return 'two and a half of five stars'; + case 5: + return 'five out of ten where ten is extremely satisfied'; - case 3.0: - return 'three of five stars'; + case 6: + return 'six out of ten where ten is extremely satisfied'; - case 3.5: - return 'three and a half of five stars'; + case 7: + return 'seven out of ten where ten is extremely satisfied'; - case 4.0: - return 'four of five stars'; + case 8: + return 'eight out of ten where ten is extremely satisfied'; - case 4.5: - return 'four and a half of five stars'; + case 9: + return 'nine out of ten where ten is extremely satisfied'; - case 5.0: - return 'five of five stars'; + case 10: + return 'ten out of ten, extremely satisfied'; default: break; @@ -160,55 +161,209 @@ class RatingSlider { return 'Unexpected value: ' + value; } - moveSliderTo(value) { - let valueMax, valueMin; + calcRatingRects() { + let infoRatingRects = []; - valueMin = this.getValueMin(); - valueMax = this.getValueMax(); + const railWidth = Math.min( + Math.max(260, this.sliderNode.getBoundingClientRect().width), + 600 + ); + const rectWidth = Math.round((railWidth - RAIL_LEFT) / 10); - value = Math.min(Math.max(value, valueMin), valueMax); + let left = RAIL_LEFT; - this.sliderNode.setAttribute('aria-valuenow', value); + for (let i = 0; i < this.targetRects.length; i += 1) { + const targetNode = this.targetRects[i]; + const labelNode = this.labelTexts[i]; + + targetNode.setAttribute('x', left); + targetNode.setAttribute('y', RAIL_TOP); + targetNode.setAttribute('width', rectWidth); + targetNode.setAttribute('height', RAIL_HEIGHT); + targetNode.removeAttribute('rx'); + + this.setLabelPosition(labelNode, left, rectWidth); + + const targetInfo = { + x: left, + y: RAIL_TOP, + width: rectWidth, + height: RAIL_HEIGHT, + rx: 0, + }; + + infoRatingRects[i] = targetInfo; + + targetNode.parentNode.classList.remove('current'); + + left += rectWidth; + } + + // adjust extremely satisfied label position + const descNodes = this.sliderNode.querySelectorAll('g.rating .description'); + let descX = RAIL_LEFT; + descNodes[0].setAttribute('x', descX); + descX = Math.round(railWidth - descNodes[1].getBBox().width + 2); + descNodes[1].setAttribute('x', descX); + + return [infoRatingRects, railWidth]; + } + + calcDefaultFocusRect() { + return { + x: 2, + y: 2, + width: this.railWidth + SELECTED_SIZE, + height: RAIL_TOP + RAIL_HEIGHT + SELECTED_SIZE, + rx: SELECTED_SIZE, + }; + } + + resetRects() { + for (let i = 0; i < this.targetRects.length; i += 1) { + const targetNode = this.targetRects[i]; + const targetInfo = this.targetInfo[i]; + const labelNode = this.labelTexts[i]; + + targetNode.setAttribute('x', targetInfo.x); + targetNode.setAttribute('y', targetInfo.y); + targetNode.setAttribute('width', targetInfo.width); + targetNode.setAttribute('height', targetInfo.height); + targetNode.removeAttribute('rx'); + + this.setLabelPosition(labelNode, targetInfo.x, targetInfo.width); + + targetNode.parentNode.classList.remove('current'); + } + } + + setSelectedRatingRect(value) { + let labelNode, targetNode, targetInfo; + + const leftValue = value - 1; + const rightValue = value + 1; + + if (value > 0) { + targetNode = this.targetRects[value - 1]; + targetInfo = this.targetInfo[value - 1]; + labelNode = this.labelTexts[value - 1]; + + targetNode.parentNode.classList.add('current'); + + const rectWidth = targetInfo.width + 2 * SELECTED_SIZE; + const x = targetInfo.x - SELECTED_SIZE; + + targetNode.setAttribute('x', x); + targetNode.setAttribute('y', targetInfo.y - SELECTED_SIZE); + targetNode.setAttribute('width', rectWidth); + targetNode.setAttribute('height', targetInfo.height + 2 * SELECTED_SIZE); + targetNode.setAttribute('rx', SELECTED_SIZE); + + this.setLabelPosition(labelNode, x, rectWidth, '120%'); + } + + if (leftValue > 0) { + targetNode = this.targetRects[leftValue - 1]; + targetInfo = this.targetInfo[leftValue - 1]; + + targetNode.setAttribute('width', targetInfo.width - SELECTED_SIZE); + } + + if (rightValue <= this.valueMax && value > 0) { + targetNode = this.targetRects[rightValue - 1]; + targetInfo = this.targetInfo[rightValue - 1]; + + targetNode.setAttribute('x', targetInfo.x + SELECTED_SIZE); + targetNode.setAttribute('width', targetInfo.width - SELECTED_SIZE); + } + } + + setLabelPosition(labelNode, x, rectWidth, fontSize = '95%') { + labelNode.setAttribute('style', `font-size: ${fontSize}`); + + const labelWidth = Math.round(labelNode.getBBox().width); + const labelHeight = Math.round(labelNode.getBBox().height); + + labelNode.setAttribute( + 'x', + 2 + x + Math.round((rectWidth - labelWidth) / 2) + ); + labelNode.setAttribute( + 'y', + -1 + + RAIL_TOP + + RAIL_HEIGHT - + Math.round((RAIL_HEIGHT - labelHeight + 4) / 2) + ); + } + + setFocusRing(value) { + const size = 2 * SELECTED_SIZE; + + if (value > 0 && value <= this.valueMax) { + const targetInfo = this.targetInfo[value - 1]; + + this.focusRect.setAttribute('x', targetInfo.x - size); + this.focusRect.setAttribute('y', targetInfo.y - size); + this.focusRect.setAttribute('width', targetInfo.width + 2 * size); + this.focusRect.setAttribute('height', targetInfo.height + 2 * size); + this.focusRect.setAttribute('rx', size); + } else { + // Set ring around entire control + + this.focusRect.setAttribute('x', this.infoDefaultFocusRect.x); + this.focusRect.setAttribute('y', this.infoDefaultFocusRect.y); + this.focusRect.setAttribute('width', this.infoDefaultFocusRect.width); + this.focusRect.setAttribute('height', this.infoDefaultFocusRect.height); + this.focusRect.setAttribute('rx', SELECTED_SIZE); + } + } + + moveSliderTo(value) { + value = Math.min(Math.max(value, this.valueMin + 1), this.valueMax); + this.sliderNode.setAttribute('aria-valuenow', value); this.sliderNode.setAttribute('aria-valuetext', this.getValueText(value)); + + this.resetRects(); + this.setSelectedRatingRect(value); + this.setFocusRing(value); } onSliderKeydown(event) { - var flag = false; - var value = this.getValue(); - var valueMin = this.getValueMin(); - var valueMax = this.getValueMax(); + let flag = false; + let value = this.getValue(); switch (event.key) { case 'ArrowLeft': case 'ArrowDown': - this.moveSliderTo(value - 0.5); + this.moveSliderTo(value - 1); flag = true; break; case 'ArrowRight': case 'ArrowUp': - this.moveSliderTo(value + 0.5); + this.moveSliderTo(value + 1); flag = true; break; case 'PageDown': - this.moveSliderTo(value - 1); + this.moveSliderTo(value - 2); flag = true; break; case 'PageUp': - this.moveSliderTo(value + 1); + this.moveSliderTo(value + 2); flag = true; break; case 'Home': - this.moveSliderTo(valueMin); + this.moveSliderTo(this.valueMin + 1); flag = true; break; case 'End': - this.moveSliderTo(valueMax); + this.moveSliderTo(this.valueMax); flag = true; break; @@ -222,18 +377,15 @@ class RatingSlider { } } - addTotalStarsToRatingLabel() { + addTotalRectsToRatingLabel() { let valuetext = this.getValueTextWithMax(this.getValue()); this.sliderNode.setAttribute('aria-valuetext', valuetext); } - onRailClick(event) { - var x = this.getSVGPoint(event).x; - var min = this.getValueMin(); - var max = this.getValueMax(); - var diffX = x - this.starsX; - var value = Math.round((2 * (diffX * (max - min))) / this.starsWidth) / 2; - this.moveSliderTo(value); + onTargetClick(event) { + this.moveSliderTo( + event.currentTarget.parentNode.getAttribute('data-value') + ); event.preventDefault(); event.stopPropagation(); @@ -243,31 +395,36 @@ class RatingSlider { } onSliderPointerDown(event) { - this.isMoving = true; - + if (!this.isMoving) { + this.isMoving = true; + } event.preventDefault(); event.stopPropagation(); - - // Set focus to the clicked handle - this.sliderNode.focus(); } onPointerMove(event) { if (this.isMoving) { - var x = this.getSVGPoint(event).x; - var min = this.getValueMin(); - var max = this.getValueMax(); - var diffX = x - this.starsX; - var value = Math.round((2 * (diffX * (max - min))) / this.starsWidth) / 2; - this.moveSliderTo(value); - + this.moveSliderTo( + event.currentTarget.parentNode.getAttribute('data-value') + ); event.preventDefault(); event.stopPropagation(); } } onPointerUp() { - this.isMoving = false; + if (this.isMoving) { + this.isMoving = false; + // Set focus to the clicked handle + this.sliderNode.focus(); + } + } + + onResize() { + [this.targetInfo, this.railWidth] = this.calcRatingRects(); + this.infoDefaultFocusRect = this.calcDefaultFocusRect(); + this.setSelectedRatingRect(this.getValue()); + this.setFocusRing(this.getValue()); } } diff --git a/content/patterns/slider/examples/slider-color-viewer.html b/content/patterns/slider/examples/slider-color-viewer.html index 867dd0c16d..6fe6559ed7 100644 --- a/content/patterns/slider/examples/slider-color-viewer.html +++ b/content/patterns/slider/examples/slider-color-viewer.html @@ -46,9 +46,8 @@

Warning!

Similar examples include:

diff --git a/content/patterns/slider/examples/slider-rating.html b/content/patterns/slider/examples/slider-rating.html index e6eeb74a66..bb619bb937 100644 --- a/content/patterns/slider/examples/slider-rating.html +++ b/content/patterns/slider/examples/slider-rating.html @@ -41,13 +41,19 @@

Warning!

Following is an example of a rating input that demonstrates the Slider Pattern. - This rating widget employs a slider because the slider pattern supports step values of any size. - This particular input enables half-star steps. - A typical five-star rating widget that allows only five possible values could instead be implemented as a radio group. + This rating widget employs a slider because it enables users to choose from ten rating values, which is a relatively large number of choices for users to navigate. + For inputs with seven or fewer choices, another pattern that could be used is radio group as demonstrated by the + Rating Radio Group Example. + However, when there are more than seven choices, other patterns provide additional keyboard commands that significantly increase efficiency for users who rely on keyboard navigation to perceive options and make a selection. + These include + slider, + spin button, + combobox, + and listbox.

Similar examples include: