Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add option to make Light/Dark mode using system setting. #4341

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
3 changes: 3 additions & 0 deletions app/assets/images/icons/computer-desktop.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 29 additions & 0 deletions app/components/theme/mobile_switcher_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<div data-controller="visibility" data-action="visibility:click:outside->visibility#off" class="flex items-center space-x-2 w-full">
<button type="button" data-action="click->visibility#toggle" data-test-id="sort-select" class="relative w-full cursor-default rounded-md bg-white dark:bg-gray-700 py-1.5 pl-3 pr-10 text-left text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-600 dark:focus:ring-gray-500 sm:text-sm sm:leading-6" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label">
<span class="text-gray-600 hover:bg-gray-50 hover:text-gray-900 px-1 py-1 text-base font-medium dark:text-gray-300 dark:hover:text-gray-200 dark:hover:bg-gray-700/60 "><%= current_theme.name.capitalize %> mode</span>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<%= inline_svg_tag 'icons/arrows-up-down.svg', class: 'h-5 w-5 text-gray-400 dark:text-gray-300', aria: true, title: 'Select Theme' %>
</span>
</button>

<ul
data-visibility-target="content"
data-transition-enter=""
data-transition-enter-start=""
data-transition-enter-end=""
data-transition-leave="transition ease-in duration-100"
data-transition-leave-start="opacity-100"
data-transition-leave-end="opacity-0"
class="absolute hidden z-10 mt-1 max-h-60 w-full min-w-max rounded-md bg-white dark:bg-gray-700 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-labelledby="listbox-label" aria-activedescendant="listbox-option-3">

<ul data-controller="theme-switcher" data-theme-switcher-current-theme-value="<%= current_theme.to_s %>" data-theme-switcher-url-value="<%= themes_path(format: :turbo_stream) %>">
<% Users::Theme.default_themes.each do |theme| %>
<li class="text-gray-700 dark:text-gray-300 group flex items-center px-3 py-2 text-sm cursor-pointer" data-action="click->theme-switcher#handleThemeChangeRequest" data-theme="<%= theme.name %>">
<%= inline_svg_tag "icons/#{theme.icon}.svg", class: 'mr-3 h-5 w-5 text-gray-400 group-hover:text-gray-500 dark:text-gray-300 dark:group-hover:text-gray-400', title: "#{theme.name} mode option", aria: true %>
<%= "#{theme.name.capitalize} mode" %>
</li>
<% end %>
</ul>
</ul>
</div>
</div>
11 changes: 11 additions & 0 deletions app/components/theme/mobile_switcher_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

class Theme::MobileSwitcherComponent < ApplicationComponent
def initialize(current_theme:)
@current_theme = current_theme
end

private

attr_reader :current_theme
end
38 changes: 35 additions & 3 deletions app/components/theme/switcher_component.html.erb
Original file line number Diff line number Diff line change
@@ -1,4 +1,36 @@
<%= link_to themes_path(theme: other_theme.name), class: yass(link: type), data: { turbo_method: :put } do %>
<%= inline_svg_tag "icons/#{current_theme.icon}.svg", class: yass(icon: type), aria: true, title: 'theme icon' %>
<%= text unless icon_only? %>
<% if mobile? %>
<%= render Theme::MobileSwitcherComponent.new(current_theme:) %>
<% else %>
<div class="relative" data-controller="visibility" data-action="visibility:click:outside->visibility#off" data-visibility-visible-value="false">
<div>
<button type="button" data-action="click->visibility#toggle" class="text-gray-600 group flex items-center dark:text-gray-300 py-2 px-3" id="user-menu-button" aria-expanded="false" aria-haspopup="true">
<span class="sr-only">Open user menu</span>
<%= inline_svg_tag "icons/#{current_theme.icon}.svg", class: yass(icon: type), aria: true, title: current_theme.icon %>
</button>
</div>

<div
data-visibility-target="content"
data-transition-enter="transition ease-out duration-200"
data-transition-enter-start="transform opacity-0 scale-95"
data-transition-enter-end="transform opacity-100 scale-100"
data-transition-leave="transition ease-in duration-75"
data-transition-leave-start="transform opacity-100 scale-100"
data-transition-leave-end="transform opacity-10 scale-95"
class="hidden origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none z-50"
role="menu"
aria-orientation="vertical"
aria-labelledby="user-menu-button"
tabindex="-1">

<ul data-controller="theme-switcher" data-theme-switcher-current-theme-value="<%= current_theme.to_s %>" data-theme-switcher-url-value="<%= themes_path(format: :turbo_stream) %>">
<% Users::Theme.default_themes.each do |theme| %>
<li class="text-gray-700 dark:text-gray-300 group flex items-center px-3 py-2 text-sm cursor-pointer" data-action="click->theme-switcher#handleThemeChangeRequest" data-theme="<%= theme.name %>">
<%= inline_svg_tag "icons/#{theme.icon}.svg", class: 'mr-3 h-5 w-5 text-gray-400 group-hover:text-gray-500 dark:text-gray-300 dark:group-hover:text-gray-400', title: "#{theme.name} mode option", aria: true %>
<%= "#{theme.name.capitalize} mode" %>
</li>
<% end %>
</ul>
</div>
</div>
<% end %>
4 changes: 0 additions & 4 deletions app/components/theme/switcher_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@ def text
"#{current_theme.name.capitalize} mode"
end

def other_theme
Users::Theme.default_themes.find { |other_theme| other_theme.name != current_theme.name }
end

def icon_only?
type == :icon_only
end
Expand Down
49 changes: 49 additions & 0 deletions app/javascript/controllers/theme_switcher_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Controller } from '@hotwired/stimulus';
import { put } from '@rails/request.js';

export default class ThemeSwitcherController extends Controller {
static values = {
currentTheme: String,
url: String,
};

connect() {
const userThemePreference = this.currentThemeValue;

this.updateTheme(userThemePreference);
this.addThemeChangeListener(userThemePreference);
}

addThemeChangeListener(userThemePreference) {
const themeChangeHandler = () => this.updateTheme(userThemePreference);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', themeChangeHandler);
}

async handleThemeChangeRequest(event) {
const { theme } = event.currentTarget.dataset;

await this.updateThemeAndSendRequest(theme);
}

async updateThemeAndSendRequest(theme) {
this.updateTheme(theme);
await put(this.urlValue, { body: JSON.stringify({ theme }) });
}

updateTheme(theme) {
const validThemes = ['light', 'dark'];
const userSystemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
const selectedTheme = validThemes.includes(theme) ? theme : userSystemTheme;

this.setUserTheme(selectedTheme);
}

// eslint-disable-next-line class-methods-use-this
setUserTheme(theme) {
const rootElement = document.getElementById('root-element');
sparshalc marked this conversation as resolved.
Show resolved Hide resolved
const availableThemes = ['system', 'light', 'dark'];

availableThemes.forEach((t) => rootElement.classList.remove(t));
rootElement.classList.add(theme);
}
}
25 changes: 13 additions & 12 deletions app/models/users/theme.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@ class Theme

DEFAULT_THEMES = [
%w[light sun],
%w[dark moon]
%w[dark moon],
%w[system computer-desktop]
].freeze

def initialize(name:, icon:)
@name = name
@icon = icon
end

def self.default_themes
DEFAULT_THEMES.map { |name, icon| new(name:, icon:) }
end
Expand All @@ -19,23 +25,18 @@ def self.for(value)
default_themes.find { |theme| theme.name == value }
end

attr_reader :name, :icon

def initialize(name:, icon:)
@name = name
@icon = icon
end

def <=>(other)
name <=> other.name
end

def to_s
sparshalc marked this conversation as resolved.
Show resolved Hide resolved
name
end

def dark_mode?
name == 'dark'
end

def system_mode?
sparshalc marked this conversation as resolved.
Show resolved Hide resolved
name == 'system'
end

attr_reader :name, :icon
end
end
12 changes: 6 additions & 6 deletions spec/components/theme/switcher_component_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,22 @@

RSpec.describe Theme::SwitcherComponent, type: :component do
context 'when dark mode is enabled' do
it 'renders the light mode button' do
it 'renders the moon icon' do
component = described_class.new(current_theme: Users::Theme.for('dark'))

render_inline(component)

expect(page).to have_link(href: '/themes?theme=light')
expect(page).to have_css('title', text: 'moon')
end
end

context 'when dark mode is not enabled' do
it 'renders the dark mode button' do
component = described_class.new(current_theme: Users::Theme.for('light'))
context 'when system mode is not enabled' do
it 'renders the computer desktop icon' do
component = described_class.new(current_theme: Users::Theme.for('system'))

render_inline(component)

expect(page).to have_link(href: '/themes?theme=dark')
expect(page).to have_css('title', text: 'computer-desktop')
end
end
end
9 changes: 8 additions & 1 deletion spec/models/users/theme_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
it 'returns the default themes' do
expect(described_class.default_themes).to contain_exactly(
an_object_having_attributes(name: 'light', icon: 'sun'),
an_object_having_attributes(name: 'dark', icon: 'moon')
an_object_having_attributes(name: 'dark', icon: 'moon'),
an_object_having_attributes(name: 'system', icon: 'computer-desktop')
)
end
end
Expand Down Expand Up @@ -43,6 +44,12 @@
end
end

context 'when the theme is system default' do
it 'returns true' do
expect(described_class.new(name: 'system', icon: 'system')).to be_system_mode
end
end

context 'when the theme is light' do
it 'returns false' do
expect(described_class.new(name: 'light', icon: 'sun')).not_to be_dark_mode
Expand Down