Feature 311 Layout & theme

Sidebar Collapse ย่อแถบเมนู

Collapse the menu to an icon rail with tooltips; on small screens it becomes an off-canvas drawer.

Live demo

Everything here talks to the real Spring Boot + SQL Server back end.

The real sidebar

This switch controls the menu on the left of this page. On a phone the same button opens a drawer instead.

Shortcut: [

How it looks

Expanded: icon + name

Collapsed: icon rail (names in tooltips)

State

-sidebar
-mode at this width
-sidebar width

What to notice

  • One class on <html> switches the grid column from 16rem to 4.25rem.
  • Collapsed icons get tooltips so the menu stays usable.
  • The state is stored in ui_preference.sidebar_collapsed and rendered by the server.

Try it

Source code

14 parts, read from the files in this project
src/main/java/com/study/uiux/config/ClientIdFilter.java Anonymous client id cookie
@Component
public class ClientIdFilter extends OncePerRequestFilter {

    public static final String COOKIE = "UIUX_CLIENT";
    public static final String ATTR = "clientId";
    private static final Pattern UUID_PATTERN = Pattern.compile("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        String id = readCookie(request);
        if (id == null) {
            id = UUID.randomUUID().toString();
            ResponseCookie cookie = ResponseCookie.from(COOKIE, id)
                    .path("/").httpOnly(true).sameSite("Lax").maxAge(Duration.ofDays(365)).build();
            response.addHeader("Set-Cookie", cookie.toString());
        }
        request.setAttribute(ATTR, id);
        chain.doFilter(request, response);
    }

    private static String readCookie(HttpServletRequest request) {
        if (request.getCookies() == null) return null;
        for (Cookie c : request.getCookies()) {
            if (COOKIE.equals(c.getName()) && UUID_PATTERN.matcher(c.getValue()).matches()) return c.getValue();
        }
        return null;
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String uri = request.getRequestURI();
        return uri.startsWith("/css/") || uri.startsWith("/js/") || uri.startsWith("/art/") || uri.endsWith(".svg");
    }
}
src/main/java/com/study/uiux/web/LayoutAdvice.java The server renders the saved appearance into <html>
@ModelAttribute
public void layout(HttpServletRequest request, Model model) {
    String clientId = (String) request.getAttribute(ClientIdFilter.ATTR);
    UiPreference pref = preferences.load(clientId);
    model.addAttribute("pref", pref);
    model.addAttribute("rootStyle", String.format(Locale.ROOT, "--font-scale:%.2f;--primary:%s;--on-primary:%s",
            pref.getFontScale() / 100.0, pref.getThemeColor(), PreferenceService.onColor(pref.getThemeColor())));
    // "system" cannot be known on the server; a tiny inline script in <head> resolves it before paint.
    model.addAttribute("initialTheme", "dark".equals(pref.getThemeMode()) ? "dark" : "light");
    model.addAttribute("groups", catalog.groups());
    model.addAttribute("lessonCount", catalog.all().size());
    model.addAttribute("presets", PreferenceService.PRESETS);
    model.addAttribute("shortcutList", shortcuts.all().stream()
            .map(s -> Map.of("id", s.getId(), "keys", s.getKeys(), "action", s.getActionCode(), "description", s.getDescription(), "scope", s.getScope()))
            .toList());
    model.addAttribute("lessonIndex", catalog.all().stream()
            .map(l -> Map.of("no", l.no(), "name", l.name(), "thai", l.thai(), "group", l.group(), "url", l.url()))
            .toList());
    model.addAttribute("path", request.getRequestURI());
}
src/main/java/com/study/uiux/web/PreferenceApi.java PUT /api/preferences: the browser sends only what changed
@PutMapping
public Map<String, Object> update(@RequestAttribute(ClientIdFilter.ATTR) String clientId, @RequestBody PreferenceUpdate change) {
    return view(preferences.update(clientId, change));
}
src/main/java/com/study/uiux/service/PreferenceService.java Validate and save one change
@Transactional
public UiPreference update(String clientId, PreferenceUpdate change) {
    UiPreference pref = repository.findById(clientId).orElseGet(() -> new UiPreference(clientId));
    if (change.themeMode() != null) {
        String mode = change.themeMode().toLowerCase(Locale.ROOT);
        if (!MODES.contains(mode)) throw ApiException.badRequest("Theme mode must be light, dark or system.");
        pref.setThemeMode(mode);
    }
    if (change.fontScale() != null) {
        int scale = change.fontScale();
        if (scale < FONT_MIN || scale > FONT_MAX) {
            throw ApiException.badRequest("Font size must be between " + FONT_MIN + "% and " + FONT_MAX + "%.");
        }
        pref.setFontScale(scale);
    }
    if (change.themeColor() != null) {
        String hex = change.themeColor().toLowerCase(Locale.ROOT);
        if (!HEX.matcher(hex).matches()) throw ApiException.badRequest("Theme colour must look like #4f46e5.");
        pref.setThemeColor(hex);
    }
    if (change.sidebarCollapsed() != null) pref.setSidebarCollapsed(change.sidebarCollapsed());
    return repository.save(pref);
}
src/main/java/com/study/uiux/domain/UiPreference.java UiPreference entity
@Entity
@Table(name = "ui_preference")
public class UiPreference {

    @Id
    @Column(name = "client_id", length = 36)
    private String clientId;

    /** light, dark or system (follow the operating system). */
    @Column(name = "theme_mode", nullable = false, length = 10)
    private String themeMode = "system";

    /** Root font size in percent: 100 = 16px. */
    @Column(name = "font_scale", nullable = false)
    private int fontScale = 100;

    /** Primary colour as #rrggbb. */
    @Column(name = "theme_color", nullable = false, length = 7)
    private String themeColor = "#4f46e5";

    @Column(name = "sidebar_collapsed", nullable = false)
    private boolean sidebarCollapsed;

    @Column(name = "created_at", nullable = false)
    private Instant createdAt;

    @Column(name = "updated_at", nullable = false)
    private Instant updatedAt;

    protected UiPreference() {
    }

    public UiPreference(String clientId) {
        this.clientId = clientId;
    }

    @PrePersist
    void onCreate() {
        createdAt = updatedAt = Instant.now();
    }

    @PreUpdate
    void onUpdate() {
        updatedAt = Instant.now();
    }
src/main/resources/db/migration/V1__schema.sql ui_preference: one row per browser
-- Appearance settings. The browser keeps only a random id in the UIUX_CLIENT cookie;
-- the server reads this row before rendering, so the page never "flashes" the wrong theme.
CREATE TABLE ui_preference (
    client_id         NVARCHAR(36) NOT NULL CONSTRAINT pk_ui_preference PRIMARY KEY,
    theme_mode        NVARCHAR(10) NOT NULL CONSTRAINT df_pref_mode  DEFAULT N'system',
    font_scale        INT          NOT NULL CONSTRAINT df_pref_font  DEFAULT 100,
    theme_color       NVARCHAR(7)  NOT NULL CONSTRAINT df_pref_color DEFAULT N'#4f46e5',
    sidebar_collapsed BIT          NOT NULL CONSTRAINT df_pref_side  DEFAULT 0,
    created_at        DATETIME2(0) NOT NULL CONSTRAINT df_pref_created DEFAULT SYSUTCDATETIME(),
    updated_at        DATETIME2(0) NOT NULL CONSTRAINT df_pref_updated DEFAULT SYSUTCDATETIME(),
    CONSTRAINT ck_pref_mode  CHECK (theme_mode IN (N'light', N'dark', N'system')),
    CONSTRAINT ck_pref_font  CHECK (font_scale BETWEEN 80 AND 140),
    CONSTRAINT ck_pref_color CHECK (theme_color LIKE N'#[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]')
);
src/main/resources/templates/layout.html <html> carries the saved appearance, so CSS is right before any JS runs
<html lang="en" th:fragment="page(content)" xmlns:th="http://www.thymeleaf.org"
      th:attr="data-theme=${initialTheme},data-mode=${pref.themeMode}"
      th:style="${rootStyle}"
      th:classappend="${pref.sidebarCollapsed} ? 'sidebar-collapsed'">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title th:text="${title} + ' · UI/UX Lab'">UI/UX Lab</title>
  <!-- "System" mode can only be resolved in the browser: do it before the first paint. -->
  <script>(function () { var d = document.documentElement; if (d.dataset.mode === 'system') { d.dataset.theme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } })();</script>
src/main/resources/templates/layout.html Sidebar: full menu, icon rail when collapsed, drawer on phones
<aside class="sidebar" id="sidebar" aria-label="Lessons">
  <div class="brand">
    <a href="/" class="brand-link">
      <span class="brand-logo" aria-hidden="true"><svg class="i"><use href="#i-sparkle"/></svg></span>
      <span class="brand-text"><b>UI/UX Lab</b><small>Spring Boot · SQL Server</small></span>
    </a>
  </div>
  <nav class="nav" id="nav">
    <a href="/" class="nav-link" th:classappend="${path == '/'} ? 'active'" th:attr="aria-current=${path == '/'} ? 'page'" data-tip="Home" data-tip-pos="right" data-tip-when="collapsed">
      <svg class="i"><use href="#i-home"/></svg><span class="nav-text">Home</span>
    </a>
    <div class="nav-group" th:each="g : ${groups}">
      <div class="nav-title" th:text="${g.name}">Group</div>
      <a th:each="l : ${g.lessons}" th:href="${l.url}" class="nav-link"
         th:classappend="${lesson != null and lesson.no == l.no} ? 'active'"
         th:attr="aria-current=${lesson != null and lesson.no == l.no} ? 'page',data-tip=${l.no + ' ' + l.name}"
         data-tip-pos="right" data-tip-when="collapsed">
        <svg class="i"><use th:href="${'#i-' + l.icon}"/></svg>
        <span class="nav-text" th:text="${l.name}">Lesson</span>
        <span class="nav-no" th:text="${l.no}">306</span>
      </a>
    </div>
  </nav>
  <div class="sidebar-foot">
    <button class="nav-link collapse-btn" type="button" data-action="sidebar" aria-controls="sidebar"
            data-tip="Expand menu  [" data-tip-pos="right" data-tip-when="collapsed">
      <svg class="i"><use href="#i-sidebar"/></svg><span class="nav-text">Collapse menu</span><kbd class="nav-kbd">[</kbd>
    </button>
  </div>
</aside>
<div class="scrim" data-action="close-drawer" aria-hidden="true"></div>
src/main/resources/templates/layout.html Appearance panel: every control here calls UI.prefs.set()
<aside class="drawer" id="appearance" aria-label="Appearance" aria-hidden="true" inert>
  <div class="drawer-head">
    <h2>Appearance</h2>
    <button class="icon-btn" type="button" data-action="appearance-close" aria-label="Close"><svg class="i"><use href="#i-x"/></svg></button>
  </div>
  <div class="drawer-body">
    <section class="setting">
      <h3>Mode</h3>
      <div class="seg" role="radiogroup" aria-label="Colour mode">
        <button type="button" role="radio" data-pref-mode="light"><svg class="i"><use href="#i-sun"/></svg>Light</button>
        <button type="button" role="radio" data-pref-mode="dark"><svg class="i"><use href="#i-moon"/></svg>Dark</button>
        <button type="button" role="radio" data-pref-mode="system"><svg class="i"><use href="#i-system"/></svg>System</button>
      </div>
    </section>
    <section class="setting">
      <h3>Text size <output class="pref-font-label">100%</output></h3>
      <div class="font-row">
        <button type="button" class="btn btn-sm btn-ghost" data-pref-font-step="-10" aria-label="Smaller text">A−</button>
        <input type="range" min="80" max="140" step="5" data-pref-font aria-label="Text size">
        <button type="button" class="btn btn-sm btn-ghost" data-pref-font-step="10" aria-label="Larger text">A+</button>
      </div>
    </section>
    <section class="setting">
      <h3>Theme colour</h3>
      <div class="swatches">
        <button type="button" th:each="p : ${presets}" class="swatch" th:style="'--sw:' + ${p.hex}"
                th:attr="data-pref-color=${p.hex},aria-label=${p.name},data-tip=${p.name}"></button>
        <label class="swatch swatch-custom" data-tip="Custom colour">
          <input type="color" data-pref-color-input aria-label="Custom colour">
        </label>
      </div>
    </section>
    <section class="setting">
      <label class="switch-row">
        <span><b>Collapsed sidebar</b><small>Icons only, names in tooltips</small></span>
        <input type="checkbox" class="switch" data-pref-sidebar>
      </label>
    </section>
    <p class="muted small">Saved in SQL Server for this browser (table <code>ui_preference</code>).</p>
    <button type="button" class="btn btn-ghost w-full" data-action="prefs-reset"><svg class="i"><use href="#i-refresh"/></svg>Reset to defaults</button>
  </div>
</aside>
src/main/resources/static/css/app.css The shell: a two-column grid on desktop
.app {
  display: grid;
  grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
  min-height: 100vh;
  transition: grid-template-columns .25s var(--ease);
}
html.sidebar-collapsed .app { grid-template-columns: var(--rail-w) minmax(0, 1fr); }
src/main/resources/static/css/app.css Collapsed rail: hide the words, keep the icons
html.sidebar-collapsed .sidebar .nav-text,
html.sidebar-collapsed .sidebar .nav-no,
html.sidebar-collapsed .sidebar .nav-kbd,
html.sidebar-collapsed .sidebar .brand-text { display: none; }
html.sidebar-collapsed .sidebar .nav-title { font-size: 0; height: 1px; margin: .55rem .6rem .7rem; padding: 0; background: var(--border); }
html.sidebar-collapsed .sidebar .nav-link { justify-content: center; padding: .6rem 0; }
html.sidebar-collapsed .sidebar .brand { justify-content: center; padding: 0; }
html.sidebar-collapsed .collapse-btn .i { transform: scaleX(-1); }
src/main/resources/static/css/app.css Below 900px the sidebar becomes an off-canvas drawer
@media (max-width: 900px) {
  .app, html.sidebar-collapsed .app { grid-template-columns: minmax(0, 1fr); }
  .sidebar {
    position: fixed; inset: 0 auto 0 0; width: min(18rem, 85vw); transform: translateX(-100%);
    transition: transform .28s var(--ease); box-shadow: var(--shadow-lg);
  }
  html.drawer-open .sidebar { transform: none; }
  /* the rail makes no sense on a phone: always show names in the drawer */
  html.sidebar-collapsed .sidebar .nav-text, html.sidebar-collapsed .sidebar .nav-no, html.sidebar-collapsed .sidebar .brand-text { display: revert; }
  html.sidebar-collapsed .sidebar .nav-link { justify-content: flex-start; padding: .48rem .65rem; }
  html.sidebar-collapsed .sidebar .nav-title { font-size: .7rem; height: auto; background: none; margin: 0; padding: 0 .65rem .35rem; }
  html.sidebar-collapsed .sidebar .brand { justify-content: flex-start; padding: 0 .9rem; }
  .sidebar-foot { display: none; }
  .scrim { display: block; position: fixed; inset: 0; background: rgb(2 6 23 / .45); z-index: 35; opacity: 0; pointer-events: none; transition: opacity .25s; }
  html.drawer-open .scrim { opacity: 1; pointer-events: auto; }
  .mobile-only { display: inline-grid; }
  .search-btn { min-width: 0; width: 2.4rem; padding: 0; justify-content: center; }
  .search-btn span, .search-btn kbd { display: none; }
  .topbar { padding: 0 .75rem; gap: .4rem; }
  .page { padding: 1.25rem 1rem 2.5rem; }
}
@media (max-width: 480px) { .hide-xs { display: none !important; } h1 { font-size: 1.5rem; } }
src/main/resources/static/js/core.js UI.prefs.set(): apply at once, save to the server a moment later
const prefs = (() => {
  const state = Object.assign({ themeMode: 'system', fontScale: 100, themeColor: '#4f46e5', sidebarCollapsed: false }, cfg.prefs);
  const dark = matchMedia('(prefers-color-scheme: dark)');
  let pending = {}, saveTimer = null;

  function resolveTheme(mode) { return mode === 'system' ? (dark.matches ? 'dark' : 'light') : mode; }

  function apply(change) {
    if (change.themeMode !== undefined) {
      root.classList.add('theme-anim');
      root.dataset.mode = change.themeMode;
      root.dataset.theme = resolveTheme(change.themeMode);
      setTimeout(() => root.classList.remove('theme-anim'), 300);
    }
    if (change.fontScale !== undefined) root.style.setProperty('--font-scale', (change.fontScale / 100).toFixed(2));
    if (change.themeColor !== undefined) {
      root.style.setProperty('--primary', change.themeColor);
      root.style.setProperty('--on-primary', onColor(change.themeColor));
    }
    if (change.sidebarCollapsed !== undefined) root.classList.toggle('sidebar-collapsed', change.sidebarCollapsed);
    Object.assign(state, change);
    document.dispatchEvent(new CustomEvent('prefs:change', { detail: { ...state } }));
  }

  function set(change) {
    apply(change);
    Object.assign(pending, change);
    clearTimeout(saveTimer);
    saveTimer = setTimeout(save, 300); // dragging the slider sends one PUT, not fifty
  }

  async function save() {
    const body = pending; pending = {};
    try {
      await api('/api/preferences', { method: 'PUT', body, silent: true });
    } catch (e) {
      toast({ type: 'error', title: 'Not saved', message: e.message });
    }
  }

  async function reset() {
    const saved = await api('/api/preferences', { method: 'DELETE' });
    apply({ themeMode: saved.themeMode, fontScale: saved.fontScale, themeColor: saved.themeColor, sidebarCollapsed: saved.sidebarCollapsed });
    toast({ type: 'success', title: 'Appearance reset', message: 'The ui_preference row for this browser was deleted.' });
  }

  // "System" follows the operating system live.
  dark.addEventListener('change', () => { if (state.themeMode === 'system') apply({ themeMode: 'system' }); });

  return { get: () => ({ ...state }), set, reset, resolveTheme, toggleTheme: () => set({ themeMode: root.dataset.theme === 'dark' ? 'light' : 'dark' }) };
})();
src/main/resources/static/js/core.js Desktop: collapse to a rail. Phone: open / close the drawer.
const mobile = matchMedia('(max-width: 900px)');
function toggleSidebar() {
  if (mobile.matches) setDrawer(!root.classList.contains('drawer-open'));
  else prefs.set({ sidebarCollapsed: !prefs.get().sidebarCollapsed });
}
function setDrawer(open) {
  root.classList.toggle('drawer-open', open);
  if (open) $('#nav .nav-link.active, #nav .nav-link')?.focus();
}
mobile.addEventListener('change', () => setDrawer(false));

Are you sure?

Rename

↑↓ moveEnter openEsc close