Fossil SCM

Implement Javascript-based keyboard navigation for web UI timelines. Shortcuts N and M focus the next or previous entry, and J and K toggle between /timeline and /info views for the focused entry. See comments in the committed Javascript file for more information.

florian 2022-07-29 06:35 UTC trunk
Commit b18c4256308c783e327e402e0e64c9d6e99e9d5e64e5c699f1cc2150b0e267fe
--- src/default.css
+++ src/default.css
@@ -51,10 +51,19 @@
5151
}
5252
tr.timelineCurrent td {
5353
border-radius: 0;
5454
border-width: 0;
5555
}
56
+.timelineFocused {
57
+ background-image: url("data:image/svg+xml,%3Csvg \
58
+xmlns='http://www.w3.org/2000/svg' viewBox='0,0,1,1'%3E%3Cpath \
59
+style='fill:orange;opacity:0.5' d='M0,0h1v1h-1z'/%3E%3C/svg%3E") !important;
60
+/*Note: IE requires explicit declarations for the next three properties.*/
61
+ background-position: top left;
62
+ background-repeat: repeat repeat;
63
+ background-size: 64px 64px;
64
+}
5665
span.timelineLeaf {
5766
font-weight: bold;
5867
}
5968
span.timelineHistDsp {
6069
font-weight: bold;
6170
--- src/default.css
+++ src/default.css
@@ -51,10 +51,19 @@
51 }
52 tr.timelineCurrent td {
53 border-radius: 0;
54 border-width: 0;
55 }
 
 
 
 
 
 
 
 
 
56 span.timelineLeaf {
57 font-weight: bold;
58 }
59 span.timelineHistDsp {
60 font-weight: bold;
61
--- src/default.css
+++ src/default.css
@@ -51,10 +51,19 @@
51 }
52 tr.timelineCurrent td {
53 border-radius: 0;
54 border-width: 0;
55 }
56 .timelineFocused {
57 background-image: url("data:image/svg+xml,%3Csvg \
58 xmlns='http://www.w3.org/2000/svg' viewBox='0,0,1,1'%3E%3Cpath \
59 style='fill:orange;opacity:0.5' d='M0,0h1v1h-1z'/%3E%3C/svg%3E") !important;
60 /*Note: IE requires explicit declarations for the next three properties.*/
61 background-position: top left;
62 background-repeat: repeat repeat;
63 background-size: 64px 64px;
64 }
65 span.timelineLeaf {
66 font-weight: bold;
67 }
68 span.timelineHistDsp {
69 font-weight: bold;
70
+154
--- src/graph.js
+++ src/graph.js
@@ -798,5 +798,159 @@
798798
var txJson = dataObj.textContent || dataObj.innerText;
799799
var tx = JSON.parse(txJson);
800800
TimelineGraph(tx);
801801
}
802802
}());
803
+
804
+/*
805
+** Timeline keyboard navigation shortcuts:
806
+**
807
+** N - Select next (newer) entry.
808
+** M - Select previous (older) entry.
809
+** J - View timeline of selected entry.
810
+** K - View details of selected entry.
811
+** L - Disable keyboard navigation mode.
812
+**
813
+** When navigating to a page with a timeline display, such as /timeline, /info,
814
+** or /finfo, keyboard navigation mode needs to be "activated" first, i.e. if no
815
+** timeline entry is focused yet, pressing any of the listed keys (except L)
816
+** sets the visual focus indicator to the highlighted or current (check-out)
817
+** entry if available, or to the topmost entry otherwise. A session cookie[0] is
818
+** used to direct pages loaded in the future to enable keyboard navigation mode
819
+** and automatically set the focus indicator to the highlighted, current, or
820
+** topmost entry. Pressing N and M on the /timeline page while the topmost or
821
+** bottommost entry is focused loads the next or previous page if available,
822
+** similar to the [↑ More] and [↓ More] links. Pressing L disables keyboard
823
+** navigation, i.e. removes the focus indicator and deletes the session cookie.
824
+** When navigating backwards or forwards in browser history, the focused entry
825
+** is restored using a hidden[1] input field.
826
+**
827
+** [0]: The lifetime and values of cookies can be tracked on the /cookies page.
828
+** A session cookie is preferred over other storage APIs because Fossil already
829
+** requires cookies to be enabled for reasonable functionality, and it's more
830
+** likely that other storage APIs are blocked by users for privacy reasons, for
831
+** example.
832
+** [1]: This feature only works with a normal (text) input field hidden by CSS
833
+** styles, instead of a true hidden (by type) input field, so may cause side
834
+** effects, for example with screen readers. Moreover, this feature currently
835
+** only works with Chrome, but not with FF or IE.
836
+**
837
+** Ideas and TODOs:
838
+**
839
+** o Shortcut to select the topmost or bottommost entry, either by separate
840
+** key, or with modifiers (SHIFT+N, SHIFT+M)?
841
+** o Improve scrolling the focused element into view for browsers without the
842
+** Element.scrollIntoViewIfNeeded() function, maybe with a Polyfill, or
843
+** something similar to the scrollToSelected() function in this source file.
844
+*/
845
+(function(){
846
+ window.addEventListener('load',function(){
847
+ function focusDefaultId(){
848
+ var tn = document.querySelector('.timelineSelected .tl-nodemark') ||
849
+ document.querySelector('.timelineCurrent .tl-nodemark');
850
+ return tn ? tn.id : 'm1';
851
+ }
852
+ function focusNextId(id,dx){
853
+ var m = /^m(\d+)$/.exec(id);
854
+ return m!==null ? 'm' + (parseInt(m[1]) + dx) : null;
855
+ }
856
+ function focusRowinfoFromId(id){
857
+ for(var i=0; true; i++){
858
+ var td = document.getElementById('timeline-data-' + i);
859
+ if( !td ) break;
860
+ var ti = JSON.parse(td.textContent || td.innerText);
861
+ for( var k=0; k<ti.rowinfo.length; k++ ){
862
+ if( id=='m' + ti.rowinfo[k].id ) return {
863
+ 'b': ti.baseUrl, 'h': ti.rowinfo[k].h
864
+ };
865
+ }
866
+ }
867
+ return null;
868
+ }
869
+ function focusVisualize(id,scroll){
870
+ var td = document.querySelector('.timelineFocused');
871
+ if( td ) td.classList.remove('timelineFocused');
872
+ if( !id ) return true;
873
+ var tn = document.getElementById(id);
874
+ if( tn ){
875
+ td = tn.parentElement.nextElementSibling;
876
+ if( td ) {
877
+ td.classList.add('timelineFocused');
878
+ if( scroll ){
879
+ if( td.scrollIntoViewIfNeeded ) td.scrollIntoViewIfNeeded();
880
+ else td.scrollIntoView(false);
881
+ }
882
+ return true;
883
+ }
884
+ }
885
+ return false;
886
+ }
887
+ var kf = document.getElementById('timeline-kbfocus');
888
+ if( !kf ){
889
+ kf = document.createElement('input');
890
+ kf.type = 'text';
891
+ kf.style = 'display:none;visibility:hidden;';
892
+ kf.id = 'timeline-kbfocus';
893
+ document.body.appendChild(kf);
894
+ }
895
+ document.addEventListener('keydown',function(evt){
896
+ var
897
+ kNEXT = 78 /* N */,
898
+ kPREV = 77 /* M */,
899
+ kTMLN = 74 /* J */,
900
+ kVIEW = 75 /* K */,
901
+ kDONE = 76 /* L */;
902
+ var key = evt.which || evt.keyCode;
903
+ if( evt.target.tagName=='INPUT' ) return;
904
+ if( evt.altKey || evt.ctrlKey || evt.shiftKey ) return;
905
+ var dx = 0;
906
+ if( key==kPREV ) dx++;
907
+ else if( key==kNEXT ) dx--;
908
+ else if( key!=kTMLN && key!=kVIEW && key!=kDONE ) return;
909
+ var kf = document.getElementById('timeline-kbfocus');
910
+ if( key==kDONE ){
911
+ kf.value = '';
912
+ focusVisualize(null,false);
913
+ document.cookie =
914
+ 'fossil_timeline_kbnav=;expires=Thu, 01 Jan 1970 00:00:01 GMT;path=/';
915
+ return;
916
+ }
917
+ document.cookie = 'fossil_timeline_kbnav=1;expires=0;path=/';
918
+ var id = kf.value;
919
+ if( id && dx==0 ){
920
+ var ri = focusRowinfoFromId(id);
921
+ if( ri ){
922
+ var page = ( key==75/*K*/ ) ? '/info/' : '/timeline?c=';
923
+ var href = ri.b + page + ri.h;
924
+ if( href!=location.href.slice(-href.length) ){
925
+ location.href = href;
926
+ return;
927
+ }
928
+ }
929
+ }
930
+ else if ( id && dx!=0 ){
931
+ id = focusNextId(id,dx);
932
+ if( id && !document.getElementById(id) ){
933
+ var btn =
934
+ document.querySelector('.tl-button-' + ( dx>0 ? 'prev' : 'next' ));
935
+ if( btn ) btn.click();
936
+ return;
937
+ }
938
+ }
939
+ else if ( !id ) id = focusDefaultId();
940
+ kf.value = id;
941
+ focusVisualize(id,true);
942
+ }/*,true*/);
943
+ window.addEventListener('pageshow',function(evt){
944
+ var id;
945
+ var kf = document.getElementById('timeline-kbfocus');
946
+ if( kf ) id = kf.value;
947
+ if( !id || !focusVisualize(id,false) ){
948
+ if( document.cookie.match(/fossil_timeline_kbnav=1/) ){
949
+ id = focusDefaultId();
950
+ kf.value = id;
951
+ if( id ) focusVisualize(id,false);
952
+ }
953
+ }
954
+ },false);
955
+ },false);
956
+}());
803957
--- src/graph.js
+++ src/graph.js
@@ -798,5 +798,159 @@
798 var txJson = dataObj.textContent || dataObj.innerText;
799 var tx = JSON.parse(txJson);
800 TimelineGraph(tx);
801 }
802 }());
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
803
--- src/graph.js
+++ src/graph.js
@@ -798,5 +798,159 @@
798 var txJson = dataObj.textContent || dataObj.innerText;
799 var tx = JSON.parse(txJson);
800 TimelineGraph(tx);
801 }
802 }());
803
804 /*
805 ** Timeline keyboard navigation shortcuts:
806 **
807 ** N - Select next (newer) entry.
808 ** M - Select previous (older) entry.
809 ** J - View timeline of selected entry.
810 ** K - View details of selected entry.
811 ** L - Disable keyboard navigation mode.
812 **
813 ** When navigating to a page with a timeline display, such as /timeline, /info,
814 ** or /finfo, keyboard navigation mode needs to be "activated" first, i.e. if no
815 ** timeline entry is focused yet, pressing any of the listed keys (except L)
816 ** sets the visual focus indicator to the highlighted or current (check-out)
817 ** entry if available, or to the topmost entry otherwise. A session cookie[0] is
818 ** used to direct pages loaded in the future to enable keyboard navigation mode
819 ** and automatically set the focus indicator to the highlighted, current, or
820 ** topmost entry. Pressing N and M on the /timeline page while the topmost or
821 ** bottommost entry is focused loads the next or previous page if available,
822 ** similar to the [↑ More] and [↓ More] links. Pressing L disables keyboard
823 ** navigation, i.e. removes the focus indicator and deletes the session cookie.
824 ** When navigating backwards or forwards in browser history, the focused entry
825 ** is restored using a hidden[1] input field.
826 **
827 ** [0]: The lifetime and values of cookies can be tracked on the /cookies page.
828 ** A session cookie is preferred over other storage APIs because Fossil already
829 ** requires cookies to be enabled for reasonable functionality, and it's more
830 ** likely that other storage APIs are blocked by users for privacy reasons, for
831 ** example.
832 ** [1]: This feature only works with a normal (text) input field hidden by CSS
833 ** styles, instead of a true hidden (by type) input field, so may cause side
834 ** effects, for example with screen readers. Moreover, this feature currently
835 ** only works with Chrome, but not with FF or IE.
836 **
837 ** Ideas and TODOs:
838 **
839 ** o Shortcut to select the topmost or bottommost entry, either by separate
840 ** key, or with modifiers (SHIFT+N, SHIFT+M)?
841 ** o Improve scrolling the focused element into view for browsers without the
842 ** Element.scrollIntoViewIfNeeded() function, maybe with a Polyfill, or
843 ** something similar to the scrollToSelected() function in this source file.
844 */
845 (function(){
846 window.addEventListener('load',function(){
847 function focusDefaultId(){
848 var tn = document.querySelector('.timelineSelected .tl-nodemark') ||
849 document.querySelector('.timelineCurrent .tl-nodemark');
850 return tn ? tn.id : 'm1';
851 }
852 function focusNextId(id,dx){
853 var m = /^m(\d+)$/.exec(id);
854 return m!==null ? 'm' + (parseInt(m[1]) + dx) : null;
855 }
856 function focusRowinfoFromId(id){
857 for(var i=0; true; i++){
858 var td = document.getElementById('timeline-data-' + i);
859 if( !td ) break;
860 var ti = JSON.parse(td.textContent || td.innerText);
861 for( var k=0; k<ti.rowinfo.length; k++ ){
862 if( id=='m' + ti.rowinfo[k].id ) return {
863 'b': ti.baseUrl, 'h': ti.rowinfo[k].h
864 };
865 }
866 }
867 return null;
868 }
869 function focusVisualize(id,scroll){
870 var td = document.querySelector('.timelineFocused');
871 if( td ) td.classList.remove('timelineFocused');
872 if( !id ) return true;
873 var tn = document.getElementById(id);
874 if( tn ){
875 td = tn.parentElement.nextElementSibling;
876 if( td ) {
877 td.classList.add('timelineFocused');
878 if( scroll ){
879 if( td.scrollIntoViewIfNeeded ) td.scrollIntoViewIfNeeded();
880 else td.scrollIntoView(false);
881 }
882 return true;
883 }
884 }
885 return false;
886 }
887 var kf = document.getElementById('timeline-kbfocus');
888 if( !kf ){
889 kf = document.createElement('input');
890 kf.type = 'text';
891 kf.style = 'display:none;visibility:hidden;';
892 kf.id = 'timeline-kbfocus';
893 document.body.appendChild(kf);
894 }
895 document.addEventListener('keydown',function(evt){
896 var
897 kNEXT = 78 /* N */,
898 kPREV = 77 /* M */,
899 kTMLN = 74 /* J */,
900 kVIEW = 75 /* K */,
901 kDONE = 76 /* L */;
902 var key = evt.which || evt.keyCode;
903 if( evt.target.tagName=='INPUT' ) return;
904 if( evt.altKey || evt.ctrlKey || evt.shiftKey ) return;
905 var dx = 0;
906 if( key==kPREV ) dx++;
907 else if( key==kNEXT ) dx--;
908 else if( key!=kTMLN && key!=kVIEW && key!=kDONE ) return;
909 var kf = document.getElementById('timeline-kbfocus');
910 if( key==kDONE ){
911 kf.value = '';
912 focusVisualize(null,false);
913 document.cookie =
914 'fossil_timeline_kbnav=;expires=Thu, 01 Jan 1970 00:00:01 GMT;path=/';
915 return;
916 }
917 document.cookie = 'fossil_timeline_kbnav=1;expires=0;path=/';
918 var id = kf.value;
919 if( id && dx==0 ){
920 var ri = focusRowinfoFromId(id);
921 if( ri ){
922 var page = ( key==75/*K*/ ) ? '/info/' : '/timeline?c=';
923 var href = ri.b + page + ri.h;
924 if( href!=location.href.slice(-href.length) ){
925 location.href = href;
926 return;
927 }
928 }
929 }
930 else if ( id && dx!=0 ){
931 id = focusNextId(id,dx);
932 if( id && !document.getElementById(id) ){
933 var btn =
934 document.querySelector('.tl-button-' + ( dx>0 ? 'prev' : 'next' ));
935 if( btn ) btn.click();
936 return;
937 }
938 }
939 else if ( !id ) id = focusDefaultId();
940 kf.value = id;
941 focusVisualize(id,true);
942 }/*,true*/);
943 window.addEventListener('pageshow',function(evt){
944 var id;
945 var kf = document.getElementById('timeline-kbfocus');
946 if( kf ) id = kf.value;
947 if( !id || !focusVisualize(id,false) ){
948 if( document.cookie.match(/fossil_timeline_kbnav=1/) ){
949 id = focusDefaultId();
950 kf.value = id;
951 if( id ) focusVisualize(id,false);
952 }
953 }
954 },false);
955 },false);
956 }());
957
+2 -2
--- src/timeline.c
+++ src/timeline.c
@@ -2729,18 +2729,18 @@
27292729
if( zError ){
27302730
@ <p class="generalError">%h(zError)</p>
27312731
}
27322732
27332733
if( zNewerButton ){
2734
- @ %z(chref("button","%s",zNewerButton))%h(zNewerButtonLabel)\
2734
+ @ %z(chref("button tl-button-next","%s",zNewerButton))%h(zNewerButtonLabel)\
27352735
@ &nbsp;&uarr;</a>
27362736
}
27372737
www_print_timeline(&q, tmFlags, zThisUser, zThisTag, zBrName,
27382738
selectedRid, secondaryRid, 0);
27392739
db_finalize(&q);
27402740
if( zOlderButton ){
2741
- @ %z(chref("button","%s",zOlderButton))%h(zOlderButtonLabel)\
2741
+ @ %z(chref("button tl-button-prev","%s",zOlderButton))%h(zOlderButtonLabel)\
27422742
@ &nbsp;&darr;</a>
27432743
}
27442744
document_emit_js(/*handles pikchrs rendered above*/);
27452745
style_finish_page();
27462746
}
27472747
--- src/timeline.c
+++ src/timeline.c
@@ -2729,18 +2729,18 @@
2729 if( zError ){
2730 @ <p class="generalError">%h(zError)</p>
2731 }
2732
2733 if( zNewerButton ){
2734 @ %z(chref("button","%s",zNewerButton))%h(zNewerButtonLabel)\
2735 @ &nbsp;&uarr;</a>
2736 }
2737 www_print_timeline(&q, tmFlags, zThisUser, zThisTag, zBrName,
2738 selectedRid, secondaryRid, 0);
2739 db_finalize(&q);
2740 if( zOlderButton ){
2741 @ %z(chref("button","%s",zOlderButton))%h(zOlderButtonLabel)\
2742 @ &nbsp;&darr;</a>
2743 }
2744 document_emit_js(/*handles pikchrs rendered above*/);
2745 style_finish_page();
2746 }
2747
--- src/timeline.c
+++ src/timeline.c
@@ -2729,18 +2729,18 @@
2729 if( zError ){
2730 @ <p class="generalError">%h(zError)</p>
2731 }
2732
2733 if( zNewerButton ){
2734 @ %z(chref("button tl-button-next","%s",zNewerButton))%h(zNewerButtonLabel)\
2735 @ &nbsp;&uarr;</a>
2736 }
2737 www_print_timeline(&q, tmFlags, zThisUser, zThisTag, zBrName,
2738 selectedRid, secondaryRid, 0);
2739 db_finalize(&q);
2740 if( zOlderButton ){
2741 @ %z(chref("button tl-button-prev","%s",zOlderButton))%h(zOlderButtonLabel)\
2742 @ &nbsp;&darr;</a>
2743 }
2744 document_emit_js(/*handles pikchrs rendered above*/);
2745 style_finish_page();
2746 }
2747

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button