From ca5c18336cb0f3ff20d4fa2701d7521ac32133c1 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Fri, 6 Dec 2024 22:31:04 -0600 Subject: [PATCH] Add an HTML/JavaScript drop down menu option to parserPopUp.pl. The point of this is to make a drop down menu that can contain math mode content. A native select of course can not contain such content other than in string form which is ugly at best. The option is `useHTMLSelect`. The default value for this option is 1 which means that a native HTML select element is used. That means that the current behavior of a drop down menu is used. If `useHTMLSelect` is set to 0, then instead a Bootstrap drop down is used instead of a native HTML select. In this case the choices must be provided that satisfy the constraint that they will work directly in HTML and will also work if placed in a `\text{...}` call in LaTeX. In HTML of course `\(...\)` or `\[...\]` will work and will be typeset by MathJax. Those also work inside `\text{...}` in LaXeX. So the drop down could have choices like `\(y < \frac{3}{4}\)` or `Choice 1: \(y^2 = e^x\)`. The drop down menu is styled to appear much like the native select, and JavaScript designed to make the drop down behave much the same. There are some differences such as the down arrow for a Bootstrap drop down menu looks a little different than that of a select element, and there is a hover/focus background color change for a Bootstrap drop down. --- htdocs/js/DropDown/dropdown.js | 63 +++++++++++++ htdocs/js/DropDown/dropdown.scss | 33 +++++++ htdocs/js/Problem/problem.scss | 2 + macros/parsers/parserPopUp.pl | 153 ++++++++++++++++++++++--------- 4 files changed, 208 insertions(+), 43 deletions(-) create mode 100644 htdocs/js/DropDown/dropdown.js create mode 100644 htdocs/js/DropDown/dropdown.scss diff --git a/htdocs/js/DropDown/dropdown.js b/htdocs/js/DropDown/dropdown.js new file mode 100644 index 000000000..c10d2b2ad --- /dev/null +++ b/htdocs/js/DropDown/dropdown.js @@ -0,0 +1,63 @@ +(() => { + const setupDropdown = (dropdown) => { + const input = dropdown?.querySelector(`input[name="${dropdown.dataset.feedbackInsertElement}"]`); + const dropdownBtn = dropdown?.querySelector('button.dropdown-toggle'); + if (!dropdown || !input || !dropdownBtn) return; + + // Give the dropdown button the correct/incorrect colors. + if (input.classList.contains('correct')) dropdownBtn.classList.add('correct'); + if (input.classList.contains('incorrect')) dropdownBtn.classList.add('incorrect'); + if (input.classList.contains('partially-correct')) dropdownBtn.classList.add('partially-correct'); + + const options = Array.from(dropdown.querySelectorAll('.dropdown-item:not(.disabled)')); + + dropdown.addEventListener('shown.bs.dropdown', () => { + for (const option of options) { + if (option.classList.contains('active')) { + option.focus(); + break; + } + } + }); + + for (const option of options) { + option.addEventListener('click', () => { + options.forEach((o) => o.classList.remove('active')); + option.classList.add('active'); + input.value = option.dataset.value; + dropdownBtn.textContent = option.dataset.content; + dropdownBtn.focus(); + + if (window.MathJax) + MathJax.startup.promise = MathJax.startup.promise.then(() => MathJax.typesetPromise([dropdownBtn])); + + // If any feedback popovers are open, then update their positions. + for (const popover of document.querySelectorAll('.ww-feedback-btn')) { + bootstrap.Popover.getInstance(popover)?.update(); + } + }); + } + }; + + // Set up dropdowns that are already in the page. + document.querySelectorAll('.pg-dropdown').forEach(setupDropdown); + + // Observer that sets up MathQuill inputs. + const observer = new MutationObserver((mutationsList) => { + for (const mutation of mutationsList) { + for (const node of mutation.addedNodes) { + if (node instanceof Element) { + if (node.classList.contains('pg-dropdown')) { + setupDropdown(node); + } else { + node.querySelectorAll('.pg-dropdown').forEach(setupDropdown); + } + } + } + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + + // Stop the mutation observer when the window is closed. + window.addEventListener('unload', () => observer.disconnect()); +})(); diff --git a/htdocs/js/DropDown/dropdown.scss b/htdocs/js/DropDown/dropdown.scss new file mode 100644 index 000000000..aa18dcace --- /dev/null +++ b/htdocs/js/DropDown/dropdown.scss @@ -0,0 +1,33 @@ +.pg-dropdown { + .btn.dropdown-toggle { + --bs-btn-color: #555; + --bs-btn-bg: white; + --bs-btn-padding-y: 0.2rem; + --bs-btn-padding-x: 0.45rem; + --bs-btn-font-size: 0.85rem; + --bs-btn-border-radius: 4px; + --bs-btn-border-color: #ccc; + + &.show { + border-color: rgba(112, 154, 192, 0.8); + outline: 0; + box-shadow: + inset 0 1px 1px rgba(0, 0, 0, 0.25), + 0 0 0 0.2rem rgba(136, 187, 221, 0.8); + } + + &::after { + margin-left: 0.9em; + } + } + + .dropdown-menu { + --bs-dropdown-min-width: 100%; + } + + .dropdown-item { + --bs-dropdown-link-active-color: black; + --bs-dropdown-link-active-bg: lightgray; + --bs-dropdown-link-hover-bg: #d3d3d387; + } +} diff --git a/htdocs/js/Problem/problem.scss b/htdocs/js/Problem/problem.scss index f84255cf3..1a7bdab31 100644 --- a/htdocs/js/Problem/problem.scss +++ b/htdocs/js/Problem/problem.scss @@ -71,6 +71,7 @@ input[type='radio'], input[type='checkbox'], span[id^='mq-answer'], + .pg-dropdown .btn.dropdown-toggle, .graphtool-container { &.correct:not(:focus):not(.mq-focused) { border-color: rgba(81, 153, 81, 0.8); /* green */ @@ -118,6 +119,7 @@ input[type='radio'], input[type='checkbox'], textarea, + .pg-dropdown .btn.dropdown-toggle, select { &:focus { border-color: rgba(112, 154, 192, 0.8); diff --git a/macros/parsers/parserPopUp.pl b/macros/parsers/parserPopUp.pl index 0912a15fd..289784ca6 100644 --- a/macros/parsers/parserPopUp.pl +++ b/macros/parsers/parserPopUp.pl @@ -39,7 +39,9 @@ =head1 DESCRIPTION Note that drop-down menus cannot contain mathematical notation, only plain text. This is because the browser's native menus are used, and -these can contain only text, not mathematics or graphics. +these can contain only text, not mathematics or graphics. That is unless +the C option is set to 0. See more about that option +below. The difference between C and Cis that in HTML, the latter will have an unselectable placeholder value. This value @@ -137,6 +139,16 @@ =head1 DESCRIPTION unnecessary in a static output format.) Default: 1, except 0 for DropDownTF. +=item C 0 or 1 >>> + +If this is set to 1 (the default) then a native HTML select element will +be used for the dropdown menu. However, if this is set to 0 then a +Bootstrap Dropdown will be used instead. In this case, the answer labels +must work directly in HTML, and must also work inside C<\text{...}> in +LaTeX. Note that math mode (C<\(...\)> or C<\[...\]>) can be used in +these labels. In HTML those will be typeset by MathJax, and in hard copy +will be typeset by LaTeX. + =back To insert the drop-down into the problem text when using PGML: @@ -226,13 +238,14 @@ sub new { $context->{parser}{String} = "parser::PopUp::String"; $context->update; $self = bless { - data => [$value], - context => $context, - choices => $choices, - placeholder => $options{placeholder} // '', - showInStatic => $options{showInStatic} // 1, - values => $options{values} // [], - noindex => $options{noindex} // 0 + data => [$value], + context => $context, + choices => $choices, + placeholder => $options{placeholder} // '', + showInStatic => $options{showInStatic} // 1, + values => $options{values} // [], + noindex => $options{noindex} // 0, + useHTMLSelect => $options{useHTMLSelect} // 1 }, $class; $self->getChoiceOrder; $self->addLabelsValues; @@ -355,8 +368,19 @@ sub cmp_preprocess { } } -# Allow users to convert the value string into a label +sub quoteTeX { + my ($self, $s) = @_; + return "\\text{$s}" unless $self->{useHTMLSelect}; + return $self->SUPER::quoteTeX($s); +} + +sub quoteHTML { + my ($self, $s) = @_; + return $s unless $self->{useHTMLSelect}; + return $self->SUPER::quoteHTML($s); +} +# Allow users to convert the value string into a label sub answerLabel { my ($self, $value) = @_; my $index = $self->getIndexByValue($value); @@ -388,43 +412,86 @@ sub MENU { my $aria_label = main::generate_aria_label($name); if ($main::displayMode =~ m/^HTML/) { - $menu = main::tag( - 'div', - class => 'd-inline text-nowrap', - data_feedback_insert_element => $name, - data_feedback_insert_method => 'append_content', - main::tag( - 'select', - class => 'pg-select', - name => $name, - id => $name, - aria_label => $aria_label, - size => 1, - ( - $self->{placeholder} - ? main::tag( - 'option', - disabled => undef, - selected => undef, - value => '', - class => 'tex2jax_ignore', + if ($self->{useHTMLSelect}) { + $menu = main::tag( + 'div', + class => 'd-inline text-nowrap', + data_feedback_insert_element => $name, + data_feedback_insert_method => 'append_content', + main::tag( + 'select', + class => 'pg-select', + name => $name, + id => $name, + aria_label => $aria_label, + size => 1, + ( $self->{placeholder} - ) - : '' - ) - . join( - '', - map { - main::tag( - 'option', $self->{values}[$_] eq $answer_value ? (selected => undef) : (), - value => $self->{values}[$_], - class => 'tex2jax_ignore', - $self->quoteHTML($self->{labels}[$_], 1) + ? main::tag( + 'option', + disabled => undef, + selected => undef, + value => '', + class => 'tex2jax_ignore', + $self->{placeholder} ) - } (0 .. $#list) + : '' + ) + . join( + '', + map { + main::tag( + 'option', $self->{values}[$_] eq $answer_value ? (selected => undef) : (), + value => $self->{values}[$_], + class => 'tex2jax_ignore', + $self->quoteHTML($self->{labels}[$_], 1) + ) + } (0 .. $#list) + ) + ) + ); + } else { + main::ADD_CSS_FILE('js/DropDown/dropdown.css'); + main::ADD_JS_FILE('js/DropDown/dropdown.js', 0, { defer => undef }); + + $menu = main::tag( + 'div', + class => 'dropdown-center pg-dropdown d-inline', + data_feedback_insert_element => $name, + data_feedback_insert_method => 'append_content', + join( + '', + main::tag('input', type => 'hidden', name => $name, value => $answer_value), + main::tag( + 'button', + class => 'btn btn-outline-dark dropdown-toggle text-nowrap ', + type => 'button', + data_bs_toggle => 'dropdown', + aria_expanded => 'false', + $answer_value ne '' ? $self->answerLabel($answer_value) : defined $self->{placeholder} + && $self->{placeholder} ne '' ? $self->{placeholder} : '?' + ), + main::tag( + 'ul', + class => 'dropdown-menu', + join( + '', + map { + main::tag( + 'button', + class => 'dropdown-item' + . ($self->{values}[$_] eq $answer_value ? ' active' : ''), + type => 'button', + data_value => $self->{values}[$_], + data_content => $self->{labels}[$_], + $self->{labels}[$_] + ) + } (0 .. $#list) + ) ) - ) - ); + ) + ); + } } elsif ($main::displayMode eq 'PTX') { if ($self->{showInStatic}) { $menu = main::tag(