Browse Source

Dark mode: autodetect, profile, singelton

- use the shortcut "d" to toggle darkmode
- new option `onlyOutsideInputs` for shortcuts that should only trigger outside of input fields
- new events: 'ui:theme:changed', 'ui:theme:set' and 'ui:theme:toggle-dark-mode'. Event data contains the theme and source (source to prevent UI updates where the change happened)
- the theme gets stored in localStorage too, for quicker decision-making on startup
Felix Niklas 2 years ago
parent
commit
cd8deeefb1

+ 20 - 0
app/assets/javascripts/app/controllers/_plugin/keyboard_shortcuts.coffee

@@ -57,6 +57,7 @@ class App.KeyboardShortcutWidget extends App.Controller
               if shortcut.callback
                 @log 'debug', 'bind for', modifier
                 $(document).on('keydown.shortcuts', {keys: modifier}, (e) =>
+                  return if shortcut.onlyOutsideInputs && (_.contains(['INPUT', 'TEXTAREA'], document.activeElement.nodeName) || document.activeElement.getAttribute('contenteditable') == 'true')
                   e.preventDefault()
                   if @lastKey && @lastKey.modifier is modifier && @lastKey.time + 5500  > new Date().getTime()
                     @lastKey.count += 1
@@ -337,6 +338,25 @@ App.Config.set(
         }
       ]
     }
+    {
+      headline: __('Appearance')
+      location: 'left'
+      content: [
+        {
+          where: __('Used anywhere')
+          shortcuts: [
+            {
+              key: 'd'
+              hotkeys: false
+              onlyOutsideInputs: true
+              description: __('Toggle dark mode')
+              callback: ->
+                App.Event.trigger('ui:theme:toggle-dark-mode')
+            }
+          ]
+        }
+      ]
+    }
     {
       headline: __('Tickets')
       location: 'right'

+ 32 - 0
app/assets/javascripts/app/controllers/_plugin/theme.coffee

@@ -0,0 +1,32 @@
+class App.Theme extends App.Controller
+  constructor: ->
+    super
+
+    window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', @onMediaQueryChange)
+    @controllerBind('ui:theme:set', @set)
+    @controllerBind('ui:theme:toggle-dark-mode', @toggleDarkMode)
+
+  onMediaQueryChange: (event) =>
+    if App.Session.get('preferences').theme == 'auto'
+      @set({ theme: 'auto' })
+
+  toggleDarkMode: =>
+    @set
+      theme: if document.documentElement.dataset.theme == 'dark' then 'light' else 'dark'
+
+  set: (data) ->
+    localStorage.setItem('theme', data.theme)
+    App.Ajax.request(
+      id:          'preferences'
+      type:        'PUT'
+      url:         "#{App.Config.get('api_path')}/users/preferences"
+      data:        JSON.stringify(theme: data.theme)
+      processData: true
+    )
+    detectedTheme = data.theme
+    if data.theme == 'auto'
+      detectedTheme = if window.matchMedia('(prefers-color-scheme: dark)').matches then 'dark' else 'light'
+    document.documentElement.dataset.theme = detectedTheme
+    App.Event.trigger('ui:theme:changed', { theme: data.theme, source: data.source })
+
+App.Config.set('theme', App.Theme, 'Plugins')

+ 24 - 0
app/assets/javascripts/app/controllers/_profile/appearance.coffee

@@ -0,0 +1,24 @@
+class ProfileAppearance extends App.ControllerSubContent
+  requiredPermission: 'user_preferences.apperance'
+  header: __('Appearance')
+  events:
+    'change input[name="theme"]': 'updateTheme'
+
+  constructor: ->
+    super
+    @render()
+    @controllerBind('ui:theme:changed', @onUpdate)
+
+  render: ->
+    @html App.view('profile/appearance')(
+      theme: localStorage.getItem('theme') || App.Session.get('preferences').theme || 'light'
+    )
+
+  onUpdate: (event) =>
+    if event.source != 'profile_appearance'
+      @render()
+
+  updateTheme: (event) ->
+    App.Event.trigger('ui:theme:set', { theme: event.target.value, source: 'profile_appearance' })
+
+App.Config.set('Appearance', { prio: 900, name: __('Appearance'), parent: '#profile', target: '#profile/appearance', controller: ProfileAppearance, permission: ['user_preferences.appearance'] }, 'NavBarProfile')

+ 11 - 20
app/assets/javascripts/app/controllers/dark_mode.coffee

@@ -9,31 +9,22 @@ class App.DarkMode extends App.Controller
     @quickToggle.closest('.zammad-switch').on('click', @stopPropagation)
     @quickToggleMenuItem.on('click', @onMenuItemClick)
 
-    if localStorage.getItem('dark-mode') == 'on' || window.matchMedia('(prefers-color-scheme: dark)').matches
-      this.setMode 'on'
+    @controllerBind('ui:theme:changed', @onUpdate)
 
   stopPropagation: (event) ->
-    console.log "stopPropagation"
     event.stopPropagation()
 
   onMenuItemClick: (event) =>
     event.stopPropagation()
-    oppositeState = if @quickToggle.prop('checked') then 'off' else 'on'
-    this.setMode oppositeState
-    console.log "onMenuItemClick"
+    oppositeTheme = if @quickToggle.prop('checked') then 'light' else 'dark'
+    App.Event.trigger('ui:theme:set', { theme: oppositeTheme, source: 'quick_switch' })
 
   quickToggleChange: =>
-    state = if @quickToggle.prop('checked') then 'on' else 'off'
-    console.log "quickToggleChange", @quickToggle.prop('checked'), state
-    this.setMode state, true
-
-  setMode: (mode, silent) ->
-    enabled = mode == 'on' ? true : false
-    if mode == 'auto'
-      enabled = window.matchMedia('(prefers-color-scheme: dark)').matches
-    document.documentElement.dataset.darkMode = if enabled then 'on' else 'off'
-    localStorage.setItem('dark-mode', mode)
-    if !silent
-      @quickToggle.prop('checked', enabled)
-
-App.Config.set('DarkMode', { prio: 1000, parent: '#current_user', name: __('Dark Mode'), translate: true, toggle: 'dark-mode-quick', permission: ['user_preferences.*'] }, 'NavBarRight')
+    theme = if @quickToggle.prop('checked') then 'dark' else 'light'
+    App.Event.trigger('ui:theme:set', { theme: theme, source: 'quick_switch' })
+
+  onUpdate: (event) =>
+    if event.source != 'quick_switch'
+      @quickToggle.prop('checked', event.detectedTheme == 'dark')
+
+App.Config.set('DarkMode', { prio: 1000, parent: '#current_user', name: __('Dark Mode'), translate: true, toggle: 'dark-mode-quick', checked: (-> document.documentElement.dataset.theme == 'dark'), permission: ['user_preferences.*'] }, 'NavBarRight')

+ 1 - 1
app/assets/javascripts/app/views/navigation/personal.jst.eco

@@ -28,7 +28,7 @@
               <% if item.iconClass: %><%- @Icon(item.iconClass) %><% end %>
               <% if item.toggle: %>
                 <span class="zammad-switch zammad-switch--tiny">
-                   <input type="checkbox" id="<%- item.toggle %>-switch" <% if item.checked: %>checked<% end %>>
+                   <input type="checkbox" id="<%- item.toggle %>-switch" <% if item.checked && item.checked(): %>checked<% end %>>
                    <label for="<%- item.toggle %>-switch"></label>
                 </span>
               <% end %>

+ 38 - 0
app/assets/javascripts/app/views/profile/appearance.jst.eco

@@ -0,0 +1,38 @@
+<div class="page-header">
+  <div class="page-header-title">
+    <h1><%- @T('Appearance') %></h1>
+  </div>
+</div>
+<div class="page-content">
+  <form class="settings-entry horizontal end">
+    <div class="form-item js-theme flex">
+      <div class="form-group">
+        <div class="formGroup-label">
+          <label for="appearance"><%- @T('Theme') %></label>
+        </div>
+        <div class="controls">
+          <div class="radio">
+            <label class="inline-label radio-replacement">
+              <input type="radio" value="dark" name="theme"<%= ' checked' if @theme == 'dark' %>>
+              <%- @Icon('radio', 'icon-unchecked') %>
+              <%- @Icon('radio-checked', 'icon-checked') %>
+              <span class="label-text"><%- @T('Dark') %></span>
+            </label>
+            <label class="inline-label radio-replacement">
+              <input type="radio" value="light" name="theme"<%= ' checked' if @theme == 'light' %>>
+              <%- @Icon('radio', 'icon-unchecked') %>
+              <%- @Icon('radio-checked', 'icon-checked') %>
+              <span class="label-text"><%- @T('Light') %></span>
+            </label>
+            <label class="inline-label radio-replacement">
+              <input type="radio" value="auto" name="theme"<%= ' checked' if @theme == 'auto' %>>
+              <%- @Icon('radio', 'icon-unchecked') %>
+              <%- @Icon('radio-checked', 'icon-checked') %>
+              <span class="label-text"><%- @T('Sync with computer') %></span>
+            </label>
+          </div>
+        </div>
+      </div>
+    </div>
+  </form>
+</div>

+ 2 - 2
app/views/layouts/application.html.erb

@@ -16,8 +16,8 @@
       document.head.appendChild(polyfillScriptTag);
     }
 
-    if(window.matchMedia('(prefers-color-scheme: dark)').matches || localStorage.getItem('dark-mode') == 'on'){
-      document.documentElement.dataset.darkMode = 'on';
+    if(window.matchMedia('(prefers-color-scheme: dark)').matches || localStorage.getItem('theme') == 'dark'){
+      document.documentElement.dataset.theme = 'dark';
     }
   <% end -%>
   <% if Rails.configuration.assets.debug %>