CQS: accessible summary stats table

Closes CNVS-15310

TEST PLAN
---- ----

  - go to statistics cqs page
  - turn on screen-reader
  - locate the table and verify that it reads in a sensible manner
  - verify you hear a short description of what the table is when you
    get to it
  - verify that no column header is abbreviated anymore, so "Std.
    Deviation" now reads "Standard Deviation" and "Avg. Time" now reads
    "Average Time"
  - verify the last column for the Average Time reads a
    human-understandable duration, like 10 seconds or 5 minutes and 30
    seconds instead of zero-zero-colon-one-zero (00:10)
  - sighted users should still see the duration in timestamp-form, e.g,
    00:10 and 05:30

Change-Id: Ia15877f654a4538e939758fcf89ad45f36e52df2
Reviewed-on: https://gerrit.instructure.com/40686
Tested-by: Jenkins <jenkins@instructure.com>
QA-Review: Trevor deHaan <tdehaan@instructure.com>
Reviewed-by: Josh Simpson <jsimpson@instructure.com>
Product-Review: Derek DeVries <ddevries@instructure.com>
This commit is contained in:
Ahmad Amireh 2014-09-08 14:55:32 +03:00 committed by Derek DeVries
parent ece4d0067a
commit f3d3c5ec02
4 changed files with 198 additions and 25 deletions

View File

@ -0,0 +1,45 @@
/** @jsx React.DOM */
define(function(require) {
var React = require('react');
/**
* @class Components.SightedUserContent
*
* A component that *tries* to hide itself from screen-readers, absolutely
* expecting that you're providing a more accessible version of the resource
* using something like a ScreenReaderContent component.
*
* Be warned that this does not totally prevent all screen-readers from
* seeing this content in all modes. For example, VoiceOver in OS X will
* still see this element when running in the "Say-All" mode and read it
* along with the accessible version you're providing.
*
* > **Warning**
* >
* > Use of this component is discouraged unless there's no alternative!!!
* >
* > The only one case that justifies its use is when design provides a
* > totally inaccessible version of a resource, and you're trying to
* > accommodate the design (for sighted users,) and provide a genuine layer
* > of accessibility (for others.)
*/
var SightedUserContent = React.createClass({
getDefaultProps: function() {
return {
tagName: 'span'
};
},
render: function() {
var tagFactory = React.DOM[this.props.tagName];
return this.transferPropsTo(tagFactory({
'aria-hidden': true,
'role': 'presentation',
'aria-role': 'presentation'
}, this.props.children));
}
});
return SightedUserContent;
});

View File

@ -1,4 +1,5 @@
define(function() {
define(function(require) {
var I18n = require('i18n!quiz_statistics');
var floor = Math.floor;
var pad = function(duration) {
@ -35,5 +36,82 @@ define(function() {
}
};
/**
* Instead of rendering a timestamp as the main method does, this method will
* render a given number of seconds into a human readable sentence. This is
* the prefered alternative to present to screen-readers if you're using
* the method above to format a duration.
*
* Examples:
*
* - 1 => `1 second`
* - 32 => `32 seconds`
* - 84 => `1 minute, and 24 seconds`
* - 3684 => `1 hour, and 1 minute`
*
* Note that the seconds are discarded when the duration is longer than an
* hour.
*
* @param {Number} seconds
* Duration in seconds.
*
* @return {String}
* A human-readable string representation of the duration.
*/
secondsToTime.toReadableString = function(seconds) {
var hours, minutes, strHours, strMinutes, strSeconds;
if (seconds < 60) {
return I18n.t('duration_in_seconds', {
one: '1 second',
other: '%{count} seconds'
}, { count: floor(seconds) });
}
else if (seconds < 3600) {
minutes = floor(seconds / 60);
seconds = floor(seconds % 60);
strMinutes = I18n.t('duration_in_minutes', {
one: '1 minute',
other: '%{count} minutes'
}, { count: minutes });
strSeconds = I18n.t('duration_in_seconds', {
one: '1 second',
other: '%{count} seconds'
}, {
count: seconds
});
return I18n.t('duration_in_minutes_and_seconds', '%{minutes} and %{seconds}', {
minutes: strMinutes,
seconds: strSeconds
});
}
else {
hours = floor(seconds / 3600);
minutes = floor((seconds - hours*3600) / 60);
strMinutes = I18n.t('duration_in_minutes', {
one: '1 minute',
other: '%{count} minutes'
}, {
count: minutes
});
strHours = I18n.t('duration_in_hours', {
one: '1 hour',
other: '%{count} hours'
}, {
count: hours
});
return I18n.t('duration_in_hours_and_minutes', '%{hours} and %{minutes}', {
minutes: strMinutes,
hours: strHours
});
}
};
return secondsToTime;
});

View File

@ -7,6 +7,20 @@ define(function(require) {
var secondsToTime = require('../util/seconds_to_time');
var round = require('../util/round');
var formatNumber = require('../util/format_number');
var SightedUserContent = require('jsx!../components/sighted_user_content');
var ScreenReaderContent = require('jsx!../components/screen_reader_content');
var Column = React.createClass({
render: function() {
return (
<th scope="col">
<SightedUserContent tagName="i" className={this.props.icon} />
{' '}
{this.props.label}
</th>
);
}
});
var Summary = React.createClass({
getDefaultProps: function() {
@ -47,28 +61,29 @@ define(function(require) {
</header>
<table className="text-left">
<ScreenReaderContent tagName="caption">
{I18n.t('table_description',
'Summary statistics for all turned in submissions')
}
</ScreenReaderContent>
<thead>
<tr>
<th>
<i className="icon-quiz-stats-avg"></i>{' '}
{I18n.t('stats_mean', 'Avg Score')}
</th>
<th>
<i className="icon-quiz-stats-high"></i>{' '}
{I18n.t('stats_high', 'High Score')}
</th>
<th>
<i className="icon-quiz-stats-low"></i>{' '}
{I18n.t('stats_low', 'Low Score')}
</th>
<th>
<i className="icon-quiz-stats-deviation"></i>{' '}
{I18n.t('stats_stdev', 'Std. Deviation')}
</th>
<th>
<i className="icon-quiz-stats-time"></i>{' '}
{I18n.t('stats_avg_time', 'Avg Time')}
</th>
<Column
icon="icon-quiz-stats-avg"
label={I18n.t('mean', 'Average Score')} />
<Column
icon="icon-quiz-stats-high"
label={I18n.t('high_score', 'High Score')} />
<Column
icon="icon-quiz-stats-low"
label={I18n.t('low_score', 'Low Score')} />
<Column
icon="icon-quiz-stats-deviation"
label={I18n.t('stdev', 'Standard Deviation')} />
<Column
icon="icon-quiz-stats-time"
label={I18n.t('avg_time', 'Average Time')} />
</tr>
</thead>
@ -80,14 +95,24 @@ define(function(require) {
<td>{this.ratioFor(this.props.scoreHigh)}%</td>
<td>{this.ratioFor(this.props.scoreLow)}%</td>
<td>{formatNumber(round(this.props.scoreStdev, 2), 2)}</td>
<td>{secondsToTime(this.props.durationAverage)}</td>
<td>
<ScreenReaderContent>
{secondsToTime.toReadableString(this.props.durationAverage)}
</ScreenReaderContent>
{/*
try to hide the [HH:]MM:SS timestamp from SR users because
it's not really useful, however this doesn't work in all
modes such as the Speak-All mode (at least on VoiceOver)
*/}
<SightedUserContent>
{secondsToTime(this.props.durationAverage)}
</SightedUserContent>
</td>
</tr>
</tbody>
</table>
<ScorePercentileChart
key="chart"
scores={this.props.scores} />
<ScorePercentileChart key="chart" scores={this.props.scores} />
</div>
);
},

View File

@ -0,0 +1,25 @@
define(function(require) {
var secondsToTime = require('util/seconds_to_time');
describe('Util.secondsToTime', function() {
describe('#toReadableString', function() {
var subject = secondsToTime.toReadableString;
it('24 => 24 seconds', function() {
expect(subject(24)).toEqual('24 seconds');
});
it('84 => one minute and 24 seconds', function() {
expect(subject(84)).toEqual('1 minute and 24 seconds');
});
it('144 => 2 minutes and 24 seconds', function() {
expect(subject(144)).toEqual('2 minutes and 24 seconds');
});
it('3684 => one hour and one minute', function() {
expect(subject(3684)).toEqual('1 hour and 1 minute');
});
});
});
});