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(