Designing for Different Devices (Viewport Size, Touch/Hover, etc.)

This document outlines the APIs used to adapt Discourse’s user interface for different devices.

Viewport Size

The most important characteristic to consider is the viewport size. We design “mobile first” and then add customizations for larger devices as needed. The breakpoints we use are:

Breakpoint Size Pixels (at 16px body font size)
sm 40rem 640px
md 48rem 768px
lg 64rem 1024px
xl 80rem 1280px
2xl 96rem 1536px

To use these in an SCSS file, add @use "lib/viewport"; at the top of the file, then use one of the available mixins:

@use "lib/viewport";

@include viewport.from(lg) {
  // SCSS rules here will be applied to
  // devices larger than the lg breakpoint
}

@include viewport.until(sm) {
  // SCSS rules here will be applied to
  // devices smaller than the sm breakpoint
}

@include viewport.between(sm, md) {
  // SCSS rules here will be applied to
  // devices with a size between the sm
  // and md breakpoints
}

In general, SCSS is the recommended way to handle layout differences based on viewport size. For advanced cases, the same breakpoints can be accessed in Ember components via the capabilities service. For example:

import Component from "@glimmer/component";
import { service } from "@ember/service";

class MyComponent extends Component {
  @service capabilities;

  <template>
    {{#if this.capabilities.viewport.lg}}
      This text will be displayed for devices larger than the lg breakpoint
    {{/if}}

    {{#unless this.capabilities.viewport.sm}}
      This text will be displayed for devices smaller than the sm breakpoint
    {{/unless}}
  </template>
}

These properties are reactive, and Ember will automatically re-render the relevant parts of the template as the browser is resized.

Touch & Hover

Some devices only have touchscreens, some only have a traditional mouse pointer, and some have both. Importantly, touchscreen users cannot “hover” over elements. Therefore, interfaces should be designed to work entirely without hover states, with hover-specific enhancements added for devices that support them.

There are several ways to detect touch/hover capability via CSS and JavaScript. For consistency, we recommend using Discourse’s helpers instead of those CSS/JS APIs directly.

For CSS, you can target the .discourse-touch and .discourse-no-touch classes, which are added to the <html> element. These are determined based on the (any-pointer: coarse) media query.

For example:

html.discourse-touch {
  // SCSS rules here will apply to devices with a touch screen,
  // including mobiles/tablets and laptops/desktops with touch screens.
}

html.discourse-no-touch {
  // SCSS rules here will apply to devices with no touch screen.
}

This information is also available in Ember components via the capabilities service:

import Component from "@glimmer/component";
import { service } from "@ember/service";

class MyComponent extends Component {
  @service capabilities;

  <template>
    {{#if this.capabilities.touch}}
      This text will be displayed for devices with a touch screen
    {{/if}}

    {{#unless this.capabilities.touch}}
      This text will be displayed for devices with no touch screen
    {{/unless}}
  </template>
}

Legacy Mobile / Desktop Modes

Historically, Discourse shipped two completely different layouts and stylesheets for “mobile” and “desktop” views, based on the browser’s user-agent. Developers would target these modes by putting CSS in specific mobile/desktop directories, by using the .mobile-view/.desktop-view HTML classes, and the site.mobileView boolean in JavaScript.

These techniques are now considered deprecated and should be replaced with the viewport and capability-based strategies discussed above. We will be removing the dedicated modes in the near future, making “mobile mode” an alias for “viewport width less than sm” for backwards compatibility.


This document is version controlled - suggest changes on github.

12 лайков

Значит, что-то вроде этого будет устаревшим?

@service site;
...
const mobileView = this.site.mobileView;

Если вы делаете это в статическом контексте, например в инициализаторе, то да, это не будет совместимо с предстоящим «мобильным режимом на основе размера области просмотра» (в настоящее время отключён по умолчанию, но скоро будет включён).

Если же вы выполняете проверку в контексте автоотслеживания, как в этом примере:

@service site;
...
<template>
  {{#if this.site.mobileView}}
    ...
  {{/if}}
</template>

то Ember автоматически перерендерит элементы при изменении булева значения mobileView (то есть при изменении размера окна браузера). Так что это не проблема.

Так что, чтобы убедиться: размещение в геттере устарело, но отсутствие в <template> — нет?

Размещение этого в геттере тоже допустимо, так как Ember будет автоматически отслеживать его изменения.

@service site;

get shouldRender(){
  return this.site.mobileView;
}

<template>
  {{#if this.shouldRender}}
    ...
  {{/if}}
</template>

^^ это корректно


Плохим примером будет:

export default apiInitializer((api) => {
  const site = api.container.lookup("service:site");
  if(site.mobileView){
    api.renderInOutlet("some-outlet", <template>My content</template>)
  }
});

Потому что в такой ситуации mobileView проверяется только при запуске приложения. Изменение размера окна браузера не перезапустит инициализатор.

Поэтому следует рефакторить код примерно так:

export default apiInitializer((api) => {
  const site = api.container.lookup("service:site");
  api.renderInOutlet("some-outlet", <template>
    {{#if site.mobileView}}My content{{/if}}
  </template>);
});

Таким образом: изменения mobileView будут учитываться при изменении размера окна браузера.


Простой способ проверить всё это: включите настройку сайта «Режим мобильной версии на основе области просмотра», затем измените размер окна браузера и убедитесь, что макет корректно обновляется при переключении между узкой и широкой шириной окна.

3 лайка

Теперь понятно. Спасибо за объяснение!

1 лайк

Уведомление об устаревании: Использование capabilities.viewport.sm на этапе инициализации сайта не рекомендуется. Применение этих значений во время инициализации может привести к ошибкам и несоответствиям при изменении размера окна браузера. Пожалуйста, перенесите эти проверки в компонент, трансформер или обратный вызов API, который выполняется во время рендеринга страницы. [id устаревания: discourse.static-viewport-initialization]

Я использовал инициализаторы для включения и выключения различных функций в зависимости от того, находимся ли мы в мобильном режиме (например, изменение домашней страницы по умолчанию, добавление ссылки на раздел сообщества). Эти функции не предназначены для динамического и реактивного поведения. Буду благодарен за предложения, как решить эти задачи и избежать данного уведомления.

1 лайк

Общая рекомендация такова: не делайте этого. Почему такой опыт должен различаться в зависимости от размера экрана?

Полезное упражнение для размышлений: как вы ожидаете, что это будет работать на складных телефонах или планшетах, которые явно не вписываются в категории «мобильное» или «десктоп».

Если вы действительно хотите, чтобы такое изменение поведения зависело от пользовательского агента браузера (как это работало в старых режимах для мобильных и десктопов), то у нас есть capabilities.isMobileDevice, который буквально ищет слово «mobile» в строке пользовательского агента:

1 лайк

В моём случае я предоставляю возможность для десктопной версии переключить главную страницу на маршрут пересечения тегов — интерфейс которого отсутствует на мобильных устройствах… (хотя сам маршрут работает — дополнительные элементы управления просто скрыты)

… но ваш аргумент принят, я, вероятно, пересмотрю это решение!

1 лайк

Интересно! Мне интересно, намеренно ли это… Мне кажется, это должно работать на любом устройстве :thinking:

2 лайка

Так это значит, что Discourse собирается отказаться от pull-left? Потому что с этим классом довольно неудобно работать, и я бы с радостью увидел его удаление.

Это было бы действительно здорово :wink:

Решение содержится в сообщении:

Вместо условного включения компонента всегда включайте его, а компонент должен сам условно рендериться.

1 лайк

Я вас понимаю, но этот компонент мне не принадлежит — он является частью ядра и привязан к конкретному маршруту. Если бы ядро сделало его рабочим и на мобильных устройствах, и на десктопах, это было бы лучшим решением.

В текущем виде вы можете установить эту страницу в качестве главной (глобально), но на мобильных устройствах это бессмысленно, так как она не работает.

Приведу ещё один пример, где это сейчас используется не в полной мере и вызывает определённые сложности — страница «Категории». На десктопе можно оставить как есть, а на мобильном устройстве, возможно, превратить её в страницу «Последнее», скрыв панель категорий и оставив только список тем. Поскольку наличие только вида «Категории» без списка тем (на мобильном) по моему мнению, неудачно в качестве главной страницы.

Однако тогда на мобильном устройстве это уже не будет страницей «Категории» (несмотря на название маршрута) :thinking:

Ситуация усугубляется тем, что компонент темы принудительная мобильная главная страница больше не работает, так как во время инициализации невозможно определить устройство.

Поэтому я считаю, что маршруты для обнаружения контента, возможно, потребуют переосмысления с точки зрения совместимости и пригодности для используемого устройства.

2 лайка