Allow checkboxes to be indeterminate (#6593)

* Documentation for indeterminate checkboxes

* Unit tests for indeterminate checkboxes

* Implement indeterminate checkboxes

* Simplify indeterminate checkbox example

* Slightly simplify refresh logic for indeterminate

That five-line if statement can be turned into a simple assignment.

* Use "yes" and "no" for checkbox indeterminate attr

This makes the "indeterminate" attribute of the checkbox widget work the
same way as other boolean attributes of other widgets.

* Fix bug with invertTag attribute

One place in the checkbox widget code was checking invertTag for
Javascript truthiness rather than the value "yes", which could have
produced incorrect results if anyone wrote invertTag="no". Fixed.
This commit is contained in:
Robin Munn 2022-04-19 02:50:03 +07:00 committed by GitHub
parent e9fa861418
commit 8dec674121
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 122 additions and 5 deletions

View File

@ -27,6 +27,7 @@ CheckboxWidget.prototype = new Widget();
Render this widget into the DOM
*/
CheckboxWidget.prototype.render = function(parent,nextSibling) {
var isChecked;
// Save the parent dom node
this.parentDomNode = parent;
// Compute our attributes
@ -38,10 +39,14 @@ CheckboxWidget.prototype.render = function(parent,nextSibling) {
this.labelDomNode.setAttribute("class","tc-checkbox " + this.checkboxClass);
this.inputDomNode = this.document.createElement("input");
this.inputDomNode.setAttribute("type","checkbox");
if(this.getValue()) {
isChecked = this.getValue();
if(isChecked) {
this.inputDomNode.setAttribute("checked","true");
$tw.utils.addClass(this.labelDomNode,"tc-checkbox-checked");
}
if(isChecked === undefined && this.checkboxIndeterminate === "yes") {
this.inputDomNode.indeterminate = true;
}
if(this.isDisabled === "yes") {
this.inputDomNode.setAttribute("disabled",true);
}
@ -62,7 +67,7 @@ CheckboxWidget.prototype.getValue = function() {
var tiddler = this.wiki.getTiddler(this.checkboxTitle);
if(tiddler || this.checkboxFilter) {
if(this.checkboxTag) {
if(this.checkboxInvertTag) {
if(this.checkboxInvertTag === "yes") {
return !tiddler.hasTag(this.checkboxTag);
} else {
return tiddler.hasTag(this.checkboxTag);
@ -93,6 +98,14 @@ CheckboxWidget.prototype.getValue = function() {
if(this.checkboxUnchecked && !this.checkboxChecked) {
return true; // Absence of unchecked value
}
if(this.checkboxChecked && this.checkboxUnchecked) {
// Both specified but neither found: indeterminate or false, depending
if(this.checkboxIndeterminate === "yes") {
return undefined;
} else {
return false;
}
}
}
if(this.checkboxListField || this.checkboxListIndex || this.checkboxFilter) {
// Same logic applies to lists and filters
@ -122,7 +135,12 @@ CheckboxWidget.prototype.getValue = function() {
return true; // Absence of unchecked value
}
if(this.checkboxChecked && this.checkboxUnchecked) {
return false; // Both specified but neither found: default to false
// Both specified but neither found: indeterminate or false, depending
if(this.checkboxIndeterminate === "yes") {
return undefined;
} else {
return false;
}
}
// Neither specified, so empty list is false, non-empty is true
return !!list.length;
@ -265,6 +283,7 @@ CheckboxWidget.prototype.execute = function() {
this.checkboxChecked = this.getAttribute("checked");
this.checkboxUnchecked = this.getAttribute("unchecked");
this.checkboxDefault = this.getAttribute("default");
this.checkboxIndeterminate = this.getAttribute("indeterminate","no");
this.checkboxClass = this.getAttribute("class","");
this.checkboxInvertTag = this.getAttribute("invertTag","");
this.isDisabled = this.getAttribute("disabled","no");
@ -277,14 +296,15 @@ Selectively refreshes the widget if needed. Returns true if the widget or any of
*/
CheckboxWidget.prototype.refresh = function(changedTiddlers) {
var changedAttributes = this.computeAttributes();
if(changedAttributes.tiddler || changedAttributes.tag || changedAttributes.invertTag || changedAttributes.field || changedAttributes.index || changedAttributes.listField || changedAttributes.filter || changedAttributes.checked || changedAttributes.unchecked || changedAttributes["default"] || changedAttributes["class"] || changedAttributes.disabled) {
if(changedAttributes.tiddler || changedAttributes.tag || changedAttributes.invertTag || changedAttributes.field || changedAttributes.index || changedAttributes.listField || changedAttributes.listIndex || changedAttributes.filter || changedAttributes.checked || changedAttributes.unchecked || changedAttributes["default"] || changedAttributes.indeterminate || changedAttributes["class"] || changedAttributes.disabled) {
this.refreshSelf();
return true;
} else {
var refreshed = false;
if(changedTiddlers[this.checkboxTitle]) {
var isChecked = this.getValue();
this.inputDomNode.checked = isChecked;
this.inputDomNode.checked = !!isChecked;
this.inputDomNode.indeterminate = (isChecked === undefined);
refreshed = true;
if(isChecked) {
$tw.utils.addClass(this.labelDomNode,"tc-checkbox-checked");

View File

@ -78,6 +78,21 @@ Tests the checkbox widget thoroughly.
startsOutChecked: false,
expectedChange: { "TiddlerOne": { expand: "yes" } }
},
{
testName: "field mode indeterminate -> true",
tiddlers: [{title: "TiddlerOne", text: "Jolly Old World", expand: "some other value"}],
widgetText: "<$checkbox tiddler='TiddlerOne' field='expand' indeterminate='yes' checked='yes' unchecked='no' />",
startsOutChecked: undefined,
expectedChange: { "TiddlerOne": { expand: "yes" } }
},
// true -> indeterminate cannot happen in field mode
{
testName: "field mode not indeterminate",
tiddlers: [{title: "TiddlerOne", text: "Jolly Old World", expand: "some other value"}],
widgetText: "<$checkbox tiddler='TiddlerOne' field='expand' indeterminate='' checked='yes' unchecked='no' />",
startsOutChecked: false,
expectedChange: { "TiddlerOne": { expand: "yes" } }
},
];
const indexModeTests = fieldModeTests.map(data => {
@ -202,6 +217,21 @@ Tests the checkbox widget thoroughly.
finalValue: true, // "no" is considered true when neither `checked` nor `unchecked` is specified
expectedChange: { "ExampleTiddler": { someField: "no" } }
},
{
testName: "list mode indeterminate -> true",
tiddlers: [{title: "Colors", colors: "orange"}],
widgetText: "<$checkbox tiddler='Colors' listField='colors' indeterminate='yes' unchecked='red' checked='green' />",
startsOutChecked: undefined,
expectedChange: { "Colors": { colors: "orange green" } }
},
// true -> indeterminate cannot happen in list mode
{
testName: "list mode not indeterminate",
tiddlers: [{title: "Colors", colors: "orange"}],
widgetText: "<$checkbox tiddler='Colors' listField='colors' unchecked='red' checked='green' />",
startsOutChecked: false,
expectedChange: { "Colors": { colors: "orange green" } }
},
];
const indexListModeTests = listModeTests.map(data => {
@ -379,6 +409,45 @@ Tests the checkbox widget thoroughly.
startsOutChecked: true,
expectedChange: { "Colors": { colors: "" } }
},
{
testName: "filter mode indeterminate -> true",
tiddlers: [{title: "Colors", colors: "orange yellow"}],
widgetText: "\\define checkActions() <$action-listops $tiddler='Colors' $field='colors' $subfilter='green'/>\n" +
"\\define uncheckActions() <$action-listops $tiddler='Colors' $field='colors' $subfilter='-green'/>\n" +
"<$checkbox filter='[list[Colors!!colors]]' indeterminate='yes' checked='green' unchecked='red' default='green' checkactions=<<checkActions>> uncheckactions=<<uncheckActions>> />",
startsOutChecked: undefined,
expectedChange: { "Colors": { colors: "orange yellow green" } }
},
{
testName: "filter mode true -> indeterminate",
tiddlers: [{title: "Colors", colors: "green orange yellow"}],
widgetText: "\\define checkActions() <$action-listops $tiddler='Colors' $field='colors' $subfilter='green'/>\n" +
"\\define uncheckActions() <$action-listops $tiddler='Colors' $field='colors' $subfilter='-green'/>\n" +
"<$checkbox filter='[list[Colors!!colors]]' indeterminate='yes' checked='green' unchecked='red' default='green' checkactions=<<checkActions>> uncheckactions=<<uncheckActions>> />",
startsOutChecked: true,
finalValue: undefined,
expectedChange: { "Colors": { colors: "orange yellow" } }
},
{
testName: "filter mode not indeterminate -> true",
tiddlers: [{title: "Colors", colors: "orange yellow"}],
widgetText: "\\define checkActions() <$action-listops $tiddler='Colors' $field='colors' $subfilter='green'/>\n" +
"\\define uncheckActions() <$action-listops $tiddler='Colors' $field='colors' $subfilter='-green'/>\n" +
"<$checkbox filter='[list[Colors!!colors]]' checked='green' unchecked='red' default='green' checkactions=<<checkActions>> uncheckactions=<<uncheckActions>> />",
startsOutChecked: false,
expectedChange: { "Colors": { colors: "orange yellow green" } }
},
{
testName: "filter mode true -> not indeterminate",
tiddlers: [{title: "Colors", colors: "green orange yellow"}],
widgetText: "\\define checkActions() <$action-listops $tiddler='Colors' $field='colors' $subfilter='green'/>\n" +
"\\define uncheckActions() <$action-listops $tiddler='Colors' $field='colors' $subfilter='-green'/>\n" +
"<$checkbox filter='[list[Colors!!colors]]' checked='green' unchecked='red' default='green' checkactions=<<checkActions>> uncheckactions=<<uncheckActions>> />",
startsOutChecked: true,
finalValue: false,
expectedChange: { "Colors": { colors: "orange yellow" } }
},
];
const checkboxTestData = fieldModeTests.concat(

View File

@ -3,6 +3,8 @@ created: 20131024141900000
modified: 20220402023600000
tags: Widgets TriggeringWidgets
colors: red orange yellow blue
fruits: bananas oranges grapes
vegetables: carrots potatoes
title: CheckboxWidget
type: text/vnd.tiddlywiki
@ -29,6 +31,7 @@ The content of the `<$checkbox>` widget is displayed within an HTML `<label>` el
|checked |The value of the field corresponding to the checkbox being checked |
|unchecked |The value of the field corresponding to the checkbox being unchecked |
|default |The default value to use if the field is not defined |
|indeterminate |Whether ambiguous values can produce indeterminate checkboxes (see below) |
|class |The class that will be assigned to the label element <$macrocall $name=".tip" _="""<<.from-version "5.2.3">> `tc-checkbox` is always applied by default, as well as `tc-checkbox-checked` when checked"""/> |
|actions |<<.from-version "5.1.14">> A string containing ActionWidgets to be triggered when the status of the checkbox changes (whether it is checked or unchecked) |
|uncheckactions |<<.from-version "5.1.16">> A string containing ActionWidgets to be triggered when the checkbox is unchecked |
@ -100,3 +103,28 @@ This example creates the same checkbox as in the list mode example, selecting be
\define uncheckActions() <$action-listops $field="colors" $subfilter="red -green"/>
<$checkbox filter="[list[!!colors]]" checked="green" unchecked="red" default="red" checkactions=<<checkActions>> uncheckactions=<<uncheckActions>> > Is "green" in colors?</$checkbox><br />''colors:'' {{!!colors}}
""">>
!! Indeterminate checkboxes
If both the ''checked'' and ''unchecked'' attributes are specified, but neither one is found in the specified field (or index), the result can be ambiguous. Should the checkbox be checked or unchecked? Normally in such cases the checkbox will be unchecked, but if the ''indeterminate'' attribute is set to "yes" (default is "no"), the checkbox will instead be in an "indeterminate" state. An indeterminate checkbox counts as false for most purposes &mdash; if you click it, the checkbox will become checked and the ''checkactions'', if any, will be triggered &mdash; but indeterminate checkboxes are displayed differently in the browser.
This example shows indeterminate checkboxes being used for categories in a shopping list (which could also be sub-tasks in a todo list, or many other things). If only some items in a category are selected, the category checkbox is indeterminate. You can click on the category checkboxes to see how indeterminate states are treated the same as the unchecked state, and clicking the box checks it and applies its check actions (in this case, checking all the boxes in that category). Try editing the <<.field fruits>> and <<.field vegetables>> fields on this tiddler and see what happens to the example when you do.
<<wikitext-example-without-html """\define check-all(field-name:"items") <$action-listops $field="selected-$field-name$" $filter="[list[!!$field-name$]]" />
\define uncheck-all(field-name:"items") <$action-listops $field="selected-$field-name$" $filter="[[]]" />
<$checkbox filter="[list[!!selected-fruits]count[]]" checked={{{ [list[!!fruits]count[]] }}} unchecked="0" checkactions=<<check-all fruits>> uncheckactions=<<uncheck-all fruits>> indeterminate="yes"> fruits</$checkbox>
<ul style="list-style: none">
<$list variable="fruit" filter="[list[!!fruits]]">
<li><$checkbox listField="selected-fruits" checked=<<fruit>>> <<fruit>></$checkbox></li>
</$list>
</ul>
<$checkbox filter="[list[!!selected-vegetables]count[]]" checked={{{ [list[!!vegetables]count[]] }}} unchecked="0" checkactions=<<check-all vegetables>> uncheckactions=<<uncheck-all vegetables>> indeterminate="yes"> veggies</$checkbox>
<ul style="list-style: none">
<$list variable="veggie" filter="[list[!!vegetables]]">
<li><$checkbox listField="selected-vegetables" checked=<<veggie>>> <<veggie>></$checkbox></li>
</$list>
</ul>
<p>Selected veggies: {{!!selected-vegetables}}<br/>
Selected fruits: {{!!selected-fruits}}</p>""">>