diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb index 70aa1e92e..df9526a69 100644 --- a/app/controllers/public/api/v1/portals/base_controller.rb +++ b/app/controllers/public/api/v1/portals/base_controller.rb @@ -14,7 +14,7 @@ class Public::Api::V1::Portals::BaseController < PublicController @theme = if %w[dark light].include?(params[:theme]) params[:theme] else - '' + 'system' end end diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb index 3551b5ac6..2011bb525 100644 --- a/app/helpers/portal_helper.rb +++ b/app/helpers/portal_helper.rb @@ -8,4 +8,29 @@ module PortalHelper bg_image = theme == 'dark' ? 'grid_dark.svg' : 'grid.svg' "background: url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}" end + + def language_name(locale) + language_map = YAML.load_file(Rails.root.join('config/languages/language_map.yml')) + language_map[locale] || locale + end + + def get_theme_names(theme) + if theme == 'light' + I18n.t('public_portal.header.appearance.light') + elsif theme == 'dark' + I18n.t('public_portal.header.appearance.dark') + else + I18n.t('public_portal.header.appearance.system') + end + end + + def get_theme_icon(theme) + if theme == 'light' + 'icons/sun' + elsif theme == 'dark' + 'icons/moon' + else + 'icons/monitor' + end + end end diff --git a/app/javascript/portal/portalHelpers.js b/app/javascript/portal/portalHelpers.js index 8b36e63f9..de1a36548 100644 --- a/app/javascript/portal/portalHelpers.js +++ b/app/javascript/portal/portalHelpers.js @@ -4,7 +4,7 @@ import Vue from 'vue'; import PublicArticleSearch from './components/PublicArticleSearch.vue'; import TableOfContents from './components/TableOfContents.vue'; -export const getHeadingsFromTheArticle = () => { +export const getHeadingsfromTheArticle = () => { const rows = []; const articleElement = document.getElementById('cw-article-content'); articleElement.querySelectorAll('h1, h2, h3').forEach(element => { @@ -68,6 +68,37 @@ export const updateThemeStyles = theme => { setPortalClass(theme); }; +export const toggleAppearanceDropdown = () => { + const dropdown = document.getElementById('appearance-dropdown'); + if (!dropdown) return; + dropdown.style.display = + dropdown.style.display === 'none' || !dropdown.style.display + ? 'flex' + : 'none'; +}; + +export const updateURLParameter = (param, paramVal) => { + const urlObj = new URL(window.location); + urlObj.searchParams.set(param, paramVal); + return urlObj.toString(); +}; + +export const removeURLParameter = parameter => { + const urlObj = new URL(window.location); + urlObj.searchParams.delete(parameter); + return urlObj.toString(); +}; + +export const switchTheme = theme => { + updateThemeStyles(theme); + const newUrl = + theme !== 'system' + ? updateURLParameter('theme', theme) + : removeURLParameter('theme'); + window.location.href = newUrl; + toggleAppearanceDropdown(); +}; + export const InitializationHelpers = { navigateToLocalePage: () => { const allLocaleSwitcher = document.querySelector('.locale-switcher'); @@ -98,7 +129,7 @@ export const InitializationHelpers = { if (isOnArticlePage) { new Vue({ components: { TableOfContents }, - data: { rows: getHeadingsFromTheArticle() }, + data: { rows: getHeadingsfromTheArticle() }, template: '', }).$mount('#cw-hc-toc'); } @@ -131,6 +162,26 @@ export const InitializationHelpers = { } }, + initializeToggleButton: () => { + const toggleButton = document.getElementById('toggle-appearance'); + if (toggleButton) { + toggleButton.addEventListener('click', toggleAppearanceDropdown); + } + }, + + initializeThemeSwitchButtons: () => { + const appearanceDropdown = document.getElementById('appearance-dropdown'); + if (!appearanceDropdown) return; + appearanceDropdown.addEventListener('click', event => { + const target = event.target.closest('button[data-theme]'); + + if (target) { + const theme = target.getAttribute('data-theme'); + switchTheme(theme); + } + }); + }, + initialize: () => { if (window.portalConfig.isPlainLayoutEnabled === 'true') { InitializationHelpers.appendPlainParamToURLs(); @@ -138,7 +189,9 @@ export const InitializationHelpers = { InitializationHelpers.navigateToLocalePage(); InitializationHelpers.initializeSearch(); InitializationHelpers.initializeTableOfContents(); - // InitializationHelpers.initializeTheme(); + InitializationHelpers.initializeTheme(); + InitializationHelpers.initializeToggleButton(); + InitializationHelpers.initializeThemeSwitchButtons(); } }, diff --git a/app/javascript/portal/specs/portal.spec.js b/app/javascript/portal/specs/portal.spec.js index 69e1e81f0..e203fdd53 100644 --- a/app/javascript/portal/specs/portal.spec.js +++ b/app/javascript/portal/specs/portal.spec.js @@ -6,6 +6,9 @@ import { setPortalStyles, setPortalClass, updateThemeStyles, + toggleAppearanceDropdown, + updateURLParameter, + removeURLParameter, } from '../portalHelpers'; describe('#navigateToLocalePage', () => { @@ -126,12 +129,78 @@ describe('Theme Functions', () => { document.body.innerHTML = ''; }); - it('sets portal class based on theme', () => { + it('sets portal class to "dark" based on theme', () => { setPortalClass('dark'); - expect(mockPortalDiv.classList.contains('dark')).toBe(true); expect(mockPortalDiv.classList.contains('light')).toBe(false); }); + + it('sets portal class to "light" based on theme', () => { + setPortalClass('light'); + expect(mockPortalDiv.classList.contains('light')).toBe(true); + expect(mockPortalDiv.classList.contains('dark')).toBe(false); + }); + }); + + describe('toggleAppearanceDropdown', () => { + it('sets dropdown display to flex if initially none', () => { + document.body.innerHTML = ``; + toggleAppearanceDropdown(); + const dropdown = document.getElementById('appearance-dropdown'); + expect(dropdown.style.display).toBe('flex'); + }); + + it('sets dropdown display to none if initially flex', () => { + document.body.innerHTML = `
`; + toggleAppearanceDropdown(); + const dropdown = document.getElementById('appearance-dropdown'); + expect(dropdown.style.display).toBe('none'); + }); + + it('does nothing if dropdown element does not exist', () => { + document.body.innerHTML = ``; + expect(() => toggleAppearanceDropdown()).not.toThrow(); + }); + }); + + describe('updateURLParameter', () => { + it('updates a given parameter with a new value', () => { + const originalUrl = 'http://example.com?param=oldValue'; + delete window.location; + window.location = new URL(originalUrl); + + const updatedUrl = updateURLParameter('param', 'newValue'); + expect(updatedUrl).toContain('param=newValue'); + }); + + it('adds a new parameter if it does not exist', () => { + const originalUrl = 'http://example.com'; + delete window.location; + window.location = new URL(originalUrl); + + const updatedUrl = updateURLParameter('newParam', 'value'); + expect(updatedUrl).toContain('newParam=value'); + }); + }); + + describe('removeURLParameter', () => { + it('removes an existing parameter', () => { + const originalUrl = 'http://example.com?param=value'; + delete window.location; + window.location = new URL(originalUrl); + + const updatedUrl = removeURLParameter('param'); + expect(updatedUrl).not.toContain('param=value'); + }); + + it('does nothing if the parameter does not exist', () => { + const originalUrl = 'http://example.com/'; + delete window.location; + window.location = new URL(originalUrl); + + const updatedUrl = removeURLParameter('param'); + expect(updatedUrl).toBe(originalUrl); + }); }); describe('#updateThemeStyles', () => { @@ -223,4 +292,43 @@ describe('Theme Functions', () => { expect(mockPortalDiv.classList.contains('light')).toBe(false); }); }); + + describe('initializeToggleButton', () => { + it('adds a click listener to the toggle button', () => { + document.body.innerHTML = ``; + InitializationHelpers.initializeToggleButton(); + const toggleButton = document.getElementById('toggle-appearance'); + expect(toggleButton.onclick).toBeDefined(); + }); + + it('does nothing if the toggle button is not present', () => { + document.body.innerHTML = ``; + expect(() => + InitializationHelpers.initializeToggleButton() + ).not.toThrow(); + }); + }); + + describe('initializeThemeSwitchButtons', () => { + it('adds click listeners to theme switch buttons', () => { + document.body.innerHTML = `
`; + InitializationHelpers.initializeThemeSwitchButtons(); + const buttons = document.querySelectorAll('button[data-theme]'); + buttons.forEach(button => expect(button.onclick).toBeDefined()); + }); + + it('does nothing if theme switch buttons are not present', () => { + document.body.innerHTML = ``; + expect(() => + InitializationHelpers.initializeThemeSwitchButtons() + ).not.toThrow(); + }); + + it('does nothing if appearance-dropdown is not present', () => { + document.body.innerHTML = ``; + expect(() => + InitializationHelpers.initializeThemeSwitchButtons() + ).not.toThrow(); + }); + }); }); diff --git a/app/views/icons/_check-mark.html.erb b/app/views/icons/_check-mark.html.erb new file mode 100644 index 000000000..19020de8c --- /dev/null +++ b/app/views/icons/_check-mark.html.erb @@ -0,0 +1,3 @@ + + + diff --git a/app/views/icons/_chevron-down.html.erb b/app/views/icons/_chevron-down.html.erb new file mode 100644 index 000000000..12b05bd0e --- /dev/null +++ b/app/views/icons/_chevron-down.html.erb @@ -0,0 +1,3 @@ + + + diff --git a/app/views/icons/_chevron-right.html.erb b/app/views/icons/_chevron-right.html.erb new file mode 100644 index 000000000..ea10fc633 --- /dev/null +++ b/app/views/icons/_chevron-right.html.erb @@ -0,0 +1,3 @@ + + + diff --git a/app/views/icons/_globe.html.erb b/app/views/icons/_globe.html.erb new file mode 100644 index 000000000..63aeab697 --- /dev/null +++ b/app/views/icons/_globe.html.erb @@ -0,0 +1,5 @@ + + + + + diff --git a/app/views/icons/_monitor.html.erb b/app/views/icons/_monitor.html.erb new file mode 100644 index 000000000..cf338480c --- /dev/null +++ b/app/views/icons/_monitor.html.erb @@ -0,0 +1,5 @@ + + + + + diff --git a/app/views/icons/_moon.html.erb b/app/views/icons/_moon.html.erb new file mode 100644 index 000000000..7db34b908 --- /dev/null +++ b/app/views/icons/_moon.html.erb @@ -0,0 +1,3 @@ + + + diff --git a/app/views/icons/_palette.html.erb b/app/views/icons/_palette.html.erb new file mode 100644 index 000000000..996c92330 --- /dev/null +++ b/app/views/icons/_palette.html.erb @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/views/icons/_redirect.html.erb b/app/views/icons/_redirect.html.erb new file mode 100644 index 000000000..eb47e0f2b --- /dev/null +++ b/app/views/icons/_redirect.html.erb @@ -0,0 +1,5 @@ + + + + + diff --git a/app/views/icons/_sun.html.erb b/app/views/icons/_sun.html.erb new file mode 100644 index 000000000..a53942349 --- /dev/null +++ b/app/views/icons/_sun.html.erb @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/views/layouts/portal.html.erb b/app/views/layouts/portal.html.erb index 2ae188fc3..611aab22c 100644 --- a/app/views/layouts/portal.html.erb +++ b/app/views/layouts/portal.html.erb @@ -28,7 +28,7 @@ By default, it renders: <% end %> -
+
<% if !@is_plain_layout_enabled %> <%= render "public/api/v1/portals/header", portal: @portal %> @@ -42,7 +42,7 @@ By default, it renders: