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.
37 changes: 37 additions & 0 deletions app/components/theme/mobile_theme_switcher_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<div data-controller="visibility" data-action="visibility:click:outside->visibility#off" class="flex items-center space-x-2 w-full">
<div class="p-2 flex cursor-pointer items-center rounded-lg button--dark" data-action="click->visibility#toggle" data-testid="sort-select dark:ring-inset">
sparshalc marked this conversation as resolved.
Show resolved Hide resolved
<span class="text-white text-base font-medium dark:text-gray-300">
<%= inline_svg_tag "icons/#{icon}.svg", aria: true, title: 'Select icon' %>
</span>
<button type="button" class="focus:outline-none text-white px-4 font-medium dark:text-gray-300" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label">
<%= text %>
</button>
<%= inline_svg_tag 'icons/chevron-down.svg', class: 'w-4 h-4 text-white group-hover:text-gray-500 dark:text-gray-200 dark:group-hover:text-gray-200', aria: true, title: 'Select icon' %>
</div>

<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">

<% options.each do |option| %>
<li class="text-gray-900 dark:text-gray-200 relative cursor-default select-none py-2 pl-8 pr-4" id="listbox-option-0" role="option">
<%= link_to themes_path(theme: option.fetch(:value)), tabindex: '-1', data: { turbo_method: :put, controller: 'theme-switcher' } do %>
<span class="font-normal text-sm block <%= 'font-semibold' if selected?(option) %>"><%= option.fetch(:label) %> </span>
<% end %>
<% if selected[:value] == option.fetch(:value) %>
<span class="text-gray-800 dark:text-gray-300 absolute inset-y-0 left-0 flex items-center pl-2">
<%= inline_svg_tag 'icons/check.svg', class: 'h-4 w-4', aria: true, title: 'Selected option' %>
</span>
<% end %>
</li>
<% end %>

</ul>
</div>
</div>
33 changes: 33 additions & 0 deletions app/components/theme/mobile_theme_switcher_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# frozen_string_literal: true

class Theme::MobileThemeSwitcherComponent < ApplicationComponent
sparshalc marked this conversation as resolved.
Show resolved Hide resolved
def initialize(options:, selected: {})
@options = options
@selected = selected
end

def text
"#{@selected[:value].capitalize} mode"
end

def icon
case @selected[:value]
when 'dark'
'moon'
when 'light'
'sun'
else
'computer-desktop'
end
end

private

attr_reader :options, :selected

def selected?(option)
return option[:light] if selected.compact.empty?

option[:value] == params[:theme]
end
end
40 changes: 37 additions & 3 deletions app/components/theme/switcher_component.html.erb
Original file line number Diff line number Diff line change
@@ -1,4 +1,38 @@
<%= 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::MobileThemeSwitcherComponent.new(
sparshalc marked this conversation as resolved.
Show resolved Hide resolved
selected: { value: current_theme.name },
options: Users::Theme.default_themes.map do |theme|
{ value: theme.name.downcase, label: "#{theme.name.capitalize} Mode"}
end
) %>
<% 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: '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">
<% Users::Theme.default_themes.each do |theme| %>
<%= link_to themes_path(theme: theme.name), class: 'text-gray-700 dark:text-gray-300 group flex items-center px-3 py-2 text-sm', data: { turbo_method: :put, controller: 'theme-switcher' } do %>
<%= 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" %>
<% end %>
<% end %>
</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
47 changes: 47 additions & 0 deletions app/javascript/controllers/theme_switcher_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Controller } from '@hotwired/stimulus';

export default class ThemeSwitcherController extends Controller {
connect() {
const userThemePreference = this.getUserThemePreference();
this.updateTheme(userThemePreference);
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', () => this.updateTheme(userThemePreference));
}

updateTheme(userThemePreference) {
const rootElement = this.getRootElement();

if (!['light', 'dark'].includes(userThemePreference)) {
const userSystemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
this.setUserTheme(rootElement, userSystemTheme);
} else {
this.setUserTheme(rootElement, userThemePreference);
}
}

getUserThemePreference() {
sparshalc marked this conversation as resolved.
Show resolved Hide resolved
this.name = 'theme';
const cookies = document.cookie.split(';');
let themePreference = null;

cookies.forEach((cookie) => {
const trimmedCookie = cookie.trim();
if (trimmedCookie.startsWith(`${this.name}=`)) {
themePreference = trimmedCookie.substring(this.name.length + 1);
}
});

return themePreference;
}

// eslint-disable-next-line class-methods-use-this
setUserTheme(rootElement, theme) {
sparshalc marked this conversation as resolved.
Show resolved Hide resolved
rootElement.removeAttribute('class');
sparshalc marked this conversation as resolved.
Show resolved Hide resolved
rootElement.classList.add(theme);
}

// eslint-disable-next-line class-methods-use-this
getRootElement() {
return document.getElementById('root-element');
}
}
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
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