diff --git a/pages/article/ArticleHandler.php b/pages/article/ArticleHandler.php index 8edeea36826..9d524bdba4f 100644 --- a/pages/article/ArticleHandler.php +++ b/pages/article/ArticleHandler.php @@ -24,6 +24,7 @@ use APP\observers\events\UsageEvent; use APP\payment\ojs\OJSCompletedPaymentDAO; use APP\payment\ojs\OJSPaymentManager; +use APP\publication\Publication; use APP\security\authorization\OjsJournalMustPublishPolicy; use APP\submission\Submission; use APP\template\TemplateManager; @@ -360,6 +361,12 @@ public function view($args, $request) $templateMgr->assign('purchaseArticleEnabled', true); } + $templateMgr->assign('pubLocaleData', $this->getMultilingualMetadataOpts( + $publication, + $templateMgr->getTemplateVars('currentLocale'), + $templateMgr->getTemplateVars('activeTheme')->getOption('showMultilingualMetadata') ?: [], + )); + if (!Hook::call('ArticleHandler::view', [&$request, &$issue, &$article, $publication])) { $templateMgr->display('frontend/pages/article.tpl'); event(new UsageEvent(Application::ASSOC_TYPE_SUBMISSION, $context, $article, null, null, $this->issue)); @@ -617,4 +624,26 @@ public function userCanViewGalley($request, $articleId, $galleyId = null) } return true; } + + /** + * Multilingual publication metadata for template: + * showMultilingualMetadataOpts - Show metadata in other languages: title (+ subtitle), keywords, abstract, etc. + */ + protected function getMultilingualMetadataOpts(Publication $publication, string $currentUILocale, array $showMultilingualMetadataOpts): array + { + $langNames = collect($publication->getLanguageNames()) + ->sortKeys(); + $langs = $langNames->keys(); + return [ + 'opts' => array_flip($showMultilingualMetadataOpts), + 'localeNames' => $langNames, + 'langAttrs' => $langNames->map(fn ($_, $l) => preg_replace(['/@.+$/', '/_/'], ['', '-'], $l))->toArray() /* remove @ and text after */, + 'localeOrder' => collect($publication->getLocalePrecedence()) + ->intersect($langs) /* remove locales not in publication's languages */ + ->concat($langs) + ->unique() + ->values() + ->toArray(), + ]; + } } diff --git a/plugins/themes/default/DefaultThemePlugin.php b/plugins/themes/default/DefaultThemePlugin.php index 25b0568deac..ae23c53ce33 100644 --- a/plugins/themes/default/DefaultThemePlugin.php +++ b/plugins/themes/default/DefaultThemePlugin.php @@ -124,6 +124,30 @@ public function init() 'default' => 'none', ]); + $this->addOption('showMultilingualMetadata', 'FieldOptions', [ + 'label' => __('plugins.themes.default.option.metadata.label'), + 'description' => __('plugins.themes.default.option.metadata.description'), + 'options' => [ + [ + 'value' => 'title', + 'label' => __('submission.title'), + ], + [ + 'value' => 'abstract', + 'label' => __('common.abstract'), + ], + [ + 'value' => 'keywords', + 'label' => __('common.keywords'), + ], + [ + 'value' => 'author', + 'label' => __('default.groups.name.author'), + ], + ], + 'default' => [], + ]); + // Load primary stylesheet $this->addStyle('stylesheet', 'styles/index.less'); diff --git a/plugins/themes/default/js/main.js b/plugins/themes/default/js/main.js index 0775189b43f..db049a4fd4a 100644 --- a/plugins/themes/default/js/main.js +++ b/plugins/themes/default/js/main.js @@ -114,3 +114,177 @@ }); })(jQuery); + +/** + * Create language buttons to show multilingual metadata + * [data-pkp-switcher-data]: Publication data for the switchers to control + * [data-pkp-switcher]: Switchers' containers + */ +(() => { + function createSwitcher(switcherContainer, data, localeOrder, localeNames) { + // Get all locales for the switcher from the data + const locales = Object.keys(Object.assign({}, ...Object.values(data))); + // The initially selected locale + let selectedLocale = null; + // Create and sort to alphabetical order + const buttons = localeOrder + .map((locale) => { + if (locales.indexOf(locale) === -1) { + return null; + } + if (!selectedLocale) { + selectedLocale = locale; + } + + const isSelectedLocale = locale === selectedLocale; + const button = document.createElement('button'); + + button.type = 'button'; + button.classList.add('pkpBadge', 'pkpBadge--button'); + button.value = locale; + button.tabIndex = '-1'; + button.role = 'option'; + button.ariaHidden = `${!isSelectedLocale}`; + button.textContent = localeNames[locale]; + if (isSelectedLocale) { + button.ariaPressed = 'false'; + button.ariaCurrent = 'true'; + button.tabIndex = '0'; + } + return button; + }) + .filter((btn) => btn) + .sort((a, b) => a.value.localeCompare(b.value)); + + // If only one button, set it disabled + if (buttons.length === 1) { + buttons[0].disabled = true; + } + + buttons.forEach((btn, i) => { + switcherContainer.appendChild(btn); + }); + + return buttons; + } + + /** + * Sync data in elements to match the selected locale + */ + function syncDataElContents(locale, propsData, langAttrs) { + for (prop in propsData.data) { + propsData.dataEls[prop].lang = langAttrs[locale]; + propsData.dataEls[prop].innerHTML = propsData.data[prop][locale] ?? ''; + } + } + + /** + * Toggle visibility of the buttons + * setValue == true => aria-hidden == true, aria-expanded == false + */ + function setVisibility(switcherContainer, buttons, currentSelected, setValue) { + // Toggle switcher container's listbox/none-role + // Listbox when buttons visible and none when hidden + switcherContainer.role = setValue ? 'none' : 'listbox'; + currentSelected.btn.ariaPressed = `${!setValue}`; + buttons.forEach((btn) => { + if (btn !== currentSelected.btn) { + btn.ariaHidden = `${setValue}`; + } + }); + switcherContainer.ariaExpanded = `${!setValue}`; + } + + function setSwitcher(propsData, switcherContainer, localeOrder, localeNames, langAttrs) { + // Create buttons and append them to the switcher container + const buttons = createSwitcher(switcherContainer, propsData.data, localeOrder, localeNames); + const currentSelected = {btn: switcherContainer.querySelector('[tabindex="0"]')}; + const focused = {btn: currentSelected.btn}; + + // Sync contents in data elements to match the selected locale (currentSelected.btn.value) + syncDataElContents(currentSelected.btn.value, propsData, langAttrs); + + // Do not add listeners if just one button, it is disabled + if (buttons.length < 2) { + return; + } + + // New button switches language and syncs data contents. Same button hides buttons. + switcherContainer.addEventListener('click', (evt) => { + const newSelectedBtn = evt.target; + if (newSelectedBtn.type === 'button') { + if (newSelectedBtn !== currentSelected.btn) { + syncDataElContents(newSelectedBtn.value, propsData, langAttrs); + // Aria + currentSelected.btn.ariaCurrent = null; + newSelectedBtn.ariaCurrent = 'true'; + currentSelected.btn.ariaPressed = null; + newSelectedBtn.ariaPressed = 'true'; + // Tab index + currentSelected.btn.tabIndex = '-1'; + newSelectedBtn.tabIndex = '0'; + // Update current and focused button + currentSelected.btn = focused.btn = newSelectedBtn; + focused.btn.focus(); + } else { + setVisibility(switcherContainer, buttons, currentSelected, switcherContainer.ariaExpanded === 'true'); + } + } + }); + + // Hide buttons when focus out + switcherContainer.addEventListener('focusout', (evt) => { + // For safari losing button focus + if (evt.target.parentElement === switcherContainer && switcherContainer.ariaExpanded === 'true') { + focused.btn.focus(); + } + if (!evt.relatedTarget || evt.relatedTarget && evt.relatedTarget.parentElement !== switcherContainer) { + setVisibility(switcherContainer, buttons, currentSelected, 'true'); + } + }); + + // Arrow keys left and right cycles button focus when buttons visible. Set focused button. + switcherContainer.addEventListener("keydown", (evt) => { + if (switcherContainer.ariaExpanded === 'true' && evt.target.type === 'button' && (evt.key === "ArrowRight" || evt.key === "ArrowLeft")) { + focused.btn = (evt.key === "ArrowRight") + ? (focused.btn.nextElementSibling ?? buttons[0]) + : (focused.btn.previousElementSibling ?? buttons[buttons.length - 1]); + focused.btn.focus(); + } + }); + } + + /** + * Set all multilingual data and elements for the switchers + */ + function setSwitchersData(dataEls, pubLocaleData) { + const propsData = {}; + dataEls.forEach((dataEl) => { + const propName = dataEl.getAttribute('data-pkp-switcher-data'); + const switcherName = pubLocaleData[propName].switcher; + if (!propsData[switcherName]) { + propsData[switcherName] = {data: [], dataEls: []}; + } + propsData[switcherName].data[propName] = pubLocaleData[propName].data; + propsData[switcherName].dataEls[propName] = dataEl; + }); + return propsData; + } + + (() => { + const switcherContainers = document.querySelectorAll('[data-pkp-switcher]'); + + if (!switcherContainers.length) return; + + const pubLocaleData = JSON.parse(pubLocaleDataJson); + const switchersDataEls = document.querySelectorAll('[data-pkp-switcher-data]'); + const switchersData = setSwitchersData(switchersDataEls, pubLocaleData); + // Create and set switchers, and sync data on the page + switcherContainers.forEach((switcherContainer) => { + const switcherName = switcherContainer.getAttribute('data-pkp-switcher'); + if (switchersData[switcherName]) { + setSwitcher(switchersData[switcherName], switcherContainer, pubLocaleData.localeOrder, pubLocaleData.localeNames, pubLocaleData.langAttrs); + } + }); + })(); +})(); \ No newline at end of file diff --git a/plugins/themes/default/locale/en/locale.po b/plugins/themes/default/locale/en/locale.po index c783e630ff5..32234234fa4 100644 --- a/plugins/themes/default/locale/en/locale.po +++ b/plugins/themes/default/locale/en/locale.po @@ -98,3 +98,21 @@ msgstr "Next slide" msgid "plugins.themes.default.prevSlide" msgstr "Previous slide" + +msgid "plugins.themes.default.option.metadata.label" +msgstr "Show article metadata on the article landing page" + +msgid "plugins.themes.default.option.metadata.description" +msgstr "Select the article metadata to show in other languages." + +msgid "plugins.themes.default.languageSwitcher.ariaDescription.titles" +msgstr "The article title and subtitle languages:" + +msgid "plugins.themes.default.languageSwitcher.ariaDescription.author" +msgstr "The author's affiliation languages:" + +msgid "plugins.themes.default.languageSwitcher.ariaDescription.keywords" +msgstr "The keywords languages:" + +msgid "plugins.themes.default.languageSwitcher.ariaDescription.abstract" +msgstr "The abstract languages:" diff --git a/plugins/themes/default/styles/objects/article_details.less b/plugins/themes/default/styles/objects/article_details.less index d8c032b0a0d..84dac2b7c02 100644 --- a/plugins/themes/default/styles/objects/article_details.less +++ b/plugins/themes/default/styles/objects/article_details.less @@ -61,11 +61,16 @@ font-weight: @bold; } - &.doi .label, - &.keywords .label { + &.doi .label { display: inline; font-size: @font-base; } + + &.keywords .label, + &.abstract .label { + display: inline-flex; + font-size: @font-base; + } } .sub_item { @@ -281,6 +286,71 @@ } } + /** + * Language switcher + */ + + .pkpBadge { + padding: 0.25em 1em; + font-size: @font-tiny; + font-weight: @normal; + line-height: 1.5em; + border: 1px solid @bg-border-color-light; + border-radius: 1.2em; + color: @text; + } + + .pkpBadge--button { + background: inherit; + text-decoration: none; + cursor: pointer; + + &:hover { + border-color: @text; + outline: 0; + } + &:disabled, + &:disabled:hover { + color: #fff; + background: @bg-dark; + border-color: @bg-dark; + cursor: not-allowed; + } + } + + [data-pkp-switcher] [tabindex="0"] { + font-weight: @bold; + } + + [data-pkp-switcher], + [data-pkp-switcher] * { + display: inline-flex; + } + + [data-pkp-switcher] [aria-hidden="true"] { + display: none; + } + + [data-pkp-switcher] [aria-hidden="false"] { + animation: fadeIn 0.7s ease-in-out; + + @keyframes fadeIn { + 0% { + display: none; + opacity: 0; + } + + 1% { + display: inline-flex; + opacity: 0; + } + + 100% { + opacity: 1; + } + } + } + @media(min-width: @screen-phone) { .entry_details { @@ -315,8 +385,7 @@ font-weight: @bold; } - &.doi .label, - &.keywords .label { + &.doi .label { display: inline; font-size: @font-base; } diff --git a/templates/frontend/objects/article_details.tpl b/templates/frontend/objects/article_details.tpl index bbc856a9815..dc53e074738 100755 --- a/templates/frontend/objects/article_details.tpl +++ b/templates/frontend/objects/article_details.tpl @@ -64,11 +64,63 @@ * @uses $licenseUrl string URL to license. Only assigned if license should be * included with published submissions. * @uses $ccLicenseBadge string An image and text with details about the license + * @uses $pubLocaleData Array of e.g. publication's locales and metadata field names to show in multiple languages. * * @hook Templates::Article::Main [] * @hook Templates::Article::Details::Reference [] * @hook Templates::Article::Details [] *} + +{** + * Filter text content of the metadata shown on the page + * If adding more filters, see elseif examples below. Filter format with params: e.g. 'default:param' + * @param string $value, The value to be filtered + * @param array $filters, E.g. ['escape', 'default:""'] + * @return string, As variable $filteredPubPropValue + *} +{function filterPubPropValue} + {foreach from=$filters item=$filter} + {if $filter === 'escape'} + {$value=$value|escape} + {elseif $filter === 'strip_unsafe_html'} + {$value=$value|strip_unsafe_html} + {elseif substr($filter, 0, 7) === 'default'} {* default:param *} + {$value=$value|default:substr($filter, 8)} + {/if} + {/foreach} + {assign "filteredPubPropValue" value=$value scope="parent" nocache} +{/function} +{** + * Publication's multilingual data to array for js and page + * Filters texts using function filterPubPropValue + * @param string $switcher, Required, Switcher's name + * @param array $data, Required, E.g. $publication->getTitles('html') + * @param array $localeOrder, Required, From $pubLocaleData.localeOrder + * @param array $filters, Optional, E.g. ['escape'] + * @param string $separator, Optional but required to implode array (e.g. keywords) + * @return array, As varaible $wrappedPubPropData + *} +{function wrapPubPropData} + {* Find the default locale from the data *} + {foreach from=$localeOrder item=$defaultLocale} + {if isset($data[$defaultLocale])}{break}{/if} + {/foreach} + {* Filter the data items using the function filterPubPropValue *} + {foreach from=$data item=$value key=$locale} + {* Join array items with separator, e.g. keywords *} + {if is_array($value)} + {$value=$separator|join:$value} + {/if} + {filterPubPropValue value=$value filters=$filters} + {$data[$locale]=$filteredPubPropValue} + {/foreach} + {$d=['switcher' => $switcher, 'data' => $data, 'defaultLocale' => $defaultLocale]} + {assign "wrappedPubPropData" value=$d scope="parent" nocache} +{/function} + +{* Language switchers' buttons text contents: locale names can be used instead of lang attribute codes by removing the line under this comment. *} +{$pubLocaleData.localeNames = $pubLocaleData.langAttrs} + {if !$heading} {assign var="heading" value="h3"} {/if} @@ -91,15 +143,42 @@ {/if} -