Настройки пространства

  • Аккаунт и публикация
  • компонентов: 13
  • пакетов: 1

Настройки, у которых одна модель сохранения — и она написана на самой странице.

Ревизия 2026-09-06

На этой странице
Во всю ширину

Что он доказывает

Страницы настроек ломаются одним способом чаще, чем всеми остальными: часть элементов срабатывает сразу, часть ждёт кнопки, и на экране не написано, что из чего. Человек трогает переключатель, уходит и не знает, случилось ли что-нибудь.

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

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

Уход из раздела уносит правки

Вкладки — не страницы. Браузер о них не знает, beforeunload не сработает, и переход в соседний раздел молча теряет всё, что было поменяно. Экран спрашивает, потому что иначе это не предупреждение, а потеря без следа.

Подпись переключателя говорит, что он делает

«Оповещения об инцидентах» называет вещь. «Уходят сразу, днём и ночью» объясняет, почему их выключают. Читают вторую фразу, и её отсутствие — то, из-за чего настройки меняют люди, не понявшие, что выбирают.

Новости продукта выключены по умолчанию, и в подписи сказано, что они такими и останутся, пока не включат. Значение по умолчанию — это решение, и записать его дешевле, чем потом защищать.

Опасное — это раздел, а не цвет

Красная кнопка среди обычных — приглашение промахнуться. Разрушительные действия живут в своей вкладке, куда надо зайти нарочно, а последствие названо числом — девять человек, двадцать четыре проекта, — а не словом «безвозвратно». Числа труднее пролистать глазами.

Использованные компоненты

Список строится из frontmatter и сверяется с исходником: компонент, добавленный в код и забытый в шапке, роняет гейт.

Пакеты:

  • @feugene/granularity

Точка входа

Тот же файл, который здесь исполняется, — examples/settings/App.vue. Отдельной копии кода не существует.

examples/settings/App.vue
<script setup lang="ts">
/**
 * `/examples/settings` — настройки пространства, работа C2.
 *
 * Экран построен вокруг одного решения: **у страницы настроек одна модель
 * сохранения, и страница называет её вслух**. Здесь она явная — ничего не
 * применяется, пока не нажать «Сохранить», и об этом написано прямо под
 * заголовком.
 *
 * Смешанная модель — самая частая беда настроек: половина переключателей
 * срабатывает сразу, половина ждёт кнопки, и по виду они неразличимы. Человек
 * трогает переключатель, уходит со страницы и не знает, сохранилось ли.
 *
 * Полоса изменений появляется вместе с первой правкой и исчезает вместе с
 * сохранением. Она же и есть ответ на вопрос «сохранилось ли»: пока она на
 * экране — нет.
 */
import { computed, reactive, ref, watch } from 'vue'
import { GrCard } from '@feugene/granularity/components/GrCard'
import { GrTabs } from '@feugene/granularity/components/GrTabs'
import { GrTabPanel, GrTabPanels } from '@feugene/granularity/components/GrTabPanels'
import { GrFormField } from '@feugene/granularity/components/GrFormField'
import { GrInput } from '@feugene/granularity/components/GrInput'
import { GrTextarea } from '@feugene/granularity/components/GrTextarea'
import { GrSelect } from '@feugene/granularity/components/GrSelect'
import { GrSwitch } from '@feugene/granularity/components/GrSwitch'
import { GrRadio } from '@feugene/granularity/components/GrRadio'
import { GrRadioGroup } from '@feugene/granularity/components/GrRadioGroup'
import { GrButton } from '@feugene/granularity/components/GrButton'
import { GrAlert } from '@feugene/granularity/components/GrAlert'
import { GrConfirmDialog } from '@feugene/granularity/components/GrConfirmDialog'
import type { SettingsData } from './data'

const props = defineProps<{ data: SettingsData }>()

/** Сохранённое состояние. С ним же сравнивается черновик. */
const saved = reactive({
  name: 'Acme Platform',
  description: '',
  locale: 'en',
  week: 'monday',
  digest: true,
  incidents: true,
  marketing: false,
  channel: 'slack',
})

const draft = reactive({ ...saved })

const tab = ref('general')
const leaving = ref<string | null>(null)
const removing = ref(false)
const justSaved = ref(false)

/** Что именно разошлось с сохранённым. Список, а не флаг: число идёт на полосу. */
const changes = computed(() =>
  (Object.keys(saved) as Array<keyof typeof saved>).filter(key => draft[key] !== saved[key]))

/** Форма слова по числу: правило здесь, слова — в содержимом. */
function plural(n: number, forms: [string, string, string]): string {
  const [one, few, many] = forms
  const mod100 = n % 100
  const mod10 = n % 10

  if (mod100 >= 11 && mod100 <= 14)
    return many
  if (mod10 === 1)
    return one
  if (mod10 >= 2 && mod10 <= 4)
    return few
  return many
}

const changeLabel = computed(() =>
  `${props.data.changed}: ${changes.value.length} ${plural(changes.value.length, props.data.changeForms)}`)

// Сообщение об успехе гаснет само: постоянная зелёная плашка перестаёт
// значить «только что сохранено» через минуту после сохранения.
watch(justSaved, (value) => {
  if (value)
    window.setTimeout(() => (justSaved.value = false), 4000)
})

function save() {
  Object.assign(saved, draft)
  justSaved.value = true
}

function discard() {
  Object.assign(draft, saved)
}

/**
 * Уход из раздела с несохранёнными правками.
 *
 * Вкладки — не страницы, и браузер о них ничего не знает: `beforeunload` здесь
 * не сработает. Значит, спрашивать обязан сам экран, иначе правки исчезают от
 * нажатия на соседнюю вкладку — без предупреждения и без следа.
 */
function switchTo(next: string) {
  if (!changes.value.length || next === tab.value) {
    tab.value = next
    return
  }

  leaving.value = next
}

function confirmLeave() {
  discard()
  if (leaving.value)
    tab.value = leaving.value
  leaving.value = null
}

const tabs = computed(() => [
  { value: 'general', label: props.data.tabs.general },
  { value: 'notifications', label: props.data.tabs.notifications },
  { value: 'danger', label: props.data.tabs.danger },
])
</script>

<template>
  <div class="screen">
    <header class="screen__head">
      <p class="screen__brand">{{ data.brand }}</p>
      <h1 class="screen__title">{{ data.title }}</h1>
      <p class="screen__subtitle">{{ data.subtitle }}</p>
      <!-- Модель сохранения названа на экране, а не только в голове автора. -->
      <p class="screen__model">{{ data.model }}</p>
    </header>

    <GrAlert v-if="justSaved" tone="success" live="polite" :title="data.saved" />

    <!--
      Переключение идёт через `switchTo`, а не через `v-model`: вкладку нельзя
      сменить, не спросив о несохранённых правках.
    -->
    <GrTabs :model-value="tab" :tabs="tabs" @update:model-value="value => switchTo(String(value))" />

    <GrTabPanels :model-value="tab">
      <GrTabPanel value="general">
        <GrCard class="screen__panel">
          <GrFormField :label="data.general.name" :hint="data.general.nameHint">
            <GrInput v-model="draft.name" />
          </GrFormField>

          <GrFormField :label="data.general.description" :hint="data.general.descriptionHint">
            <GrTextarea v-model="draft.description" :rows="3" />
          </GrFormField>

          <div class="screen__pair">
            <GrFormField :label="data.general.locale">
              <GrSelect v-model="draft.locale" :options="data.general.locales" />
            </GrFormField>

            <GrFormField :label="data.general.week">
              <GrSelect v-model="draft.week" :options="data.general.weekOptions" />
            </GrFormField>
          </div>
        </GrCard>
      </GrTabPanel>

      <GrTabPanel value="notifications">
        <GrCard class="screen__panel">
          <p class="screen__lead">{{ data.notifications.lead }}</p>

          <!--
            У каждого переключателя написано, что он делает, а не только как
            называется. «Оповещения об инцидентах» ничего не говорит о том, что
            они приходят ночью, — а это и есть то, из-за чего их выключают.
          -->
          <div class="screen__switch">
            <GrSwitch v-model="draft.digest">{{ data.notifications.digest }}</GrSwitch>
            <p class="screen__hint">{{ data.notifications.digestHint }}</p>
          </div>

          <div class="screen__switch">
            <GrSwitch v-model="draft.incidents">{{ data.notifications.incidents }}</GrSwitch>
            <p class="screen__hint">{{ data.notifications.incidentsHint }}</p>
          </div>

          <div class="screen__switch">
            <GrSwitch v-model="draft.marketing">{{ data.notifications.marketing }}</GrSwitch>
            <p class="screen__hint">{{ data.notifications.marketingHint }}</p>
          </div>

          <GrFormField :label="data.notifications.channel">
            <GrRadioGroup v-model="draft.channel" name="incident-channel">
              <GrRadio v-for="option in data.notifications.channels" :key="option.value" :value="option.value">
                <span class="screen__radio">
                  <span>{{ option.label }}</span>
                  <span class="screen__hint">{{ option.hint }}</span>
                </span>
              </GrRadio>
            </GrRadioGroup>
          </GrFormField>
        </GrCard>
      </GrTabPanel>

      <GrTabPanel value="danger">
        <GrCard class="screen__panel">
          <p class="screen__lead">{{ data.danger.lead }}</p>

          <!--
            Опасное отделено разделом, а не цветом кнопки. Красная кнопка среди
            обычных — приглашение промахнуться; отдельный раздел требует туда
            зайти.
          -->
          <div class="screen__danger">
            <div>
              <p class="screen__dangerTitle">{{ data.danger.transfer }}</p>
              <p class="screen__hint">{{ data.danger.transferHint }}</p>
            </div>
            <GrButton variant="outline">{{ data.danger.transferAction }}</GrButton>
          </div>

          <div class="screen__danger">
            <div>
              <p class="screen__dangerTitle">{{ data.danger.remove }}</p>
              <!-- Последствие названо числом, а не словом «безвозвратно». -->
              <p class="screen__hint">{{ data.danger.removeHint }}</p>
            </div>
            <GrButton variant="outline" tone="danger" @click="removing = true">
              {{ data.danger.removeAction }}
            </GrButton>
          </div>
        </GrCard>
      </GrTabPanel>
    </GrTabPanels>

    <!--
      Полоса появляется вместе с первой правкой. Пока она на экране — ничего не
      сохранено, и это единственный ответ на вопрос «сохранилось ли».
    -->
    <div v-if="changes.length" class="screen__bar" role="status">
      <strong>{{ changeLabel }}</strong>
      <div class="screen__barActions">
        <GrButton variant="ghost" @click="discard">{{ data.discard }}</GrButton>
        <GrButton @click="save">{{ data.save }}</GrButton>
      </div>
    </div>

    <!-- `leaving` хранит не «открыто ли», а **куда уходим**: без этого после
         подтверждения некуда переключаться. Диалогу отдаётся булево. -->
    <GrConfirmDialog
      :model-value="Boolean(leaving)"
      :title="data.leaveTitle"
      :description="data.leaveDescription"
      :confirm-text="data.leaveConfirm"
      :cancel-text="data.leaveCancel"
      confirm-tone="danger"
      @update:model-value="value => !value && (leaving = null)"
      @confirm="confirmLeave"
      @cancel="leaving = null"
    />

    <GrConfirmDialog
      v-model="removing"
      :title="data.danger.removeTitle"
      :description="data.danger.removeDescription"
      :confirm-text="data.danger.removeConfirm"
      confirm-tone="danger"
    />
  </div>
</template>

<style scoped>
.screen {
  display: grid;
  align-content: start;
  gap: var(--gr-space-4);
  min-height: 100dvh;
  padding: var(--gr-space-4);
  background: var(--gr-bg);
  color: var(--gr-fg);
}

.screen__brand {
  margin: 0;
  color: var(--gr-muted-fg);
  font-size: var(--gr-text-sm);
}

.screen__title {
  margin: var(--gr-space-1) 0 0;
  font-size: var(--gr-text-xl);
  font-weight: 600;
}

.screen__subtitle {
  margin: var(--gr-space-1) 0 0;
  color: var(--gr-muted-fg);
  font-size: var(--gr-text-sm);
}

.screen__model {
  margin: var(--gr-space-2) 0 0;
  font-size: var(--gr-text-sm);
}

.screen__panel {
  display: grid;
  gap: var(--gr-space-4);
  padding: var(--gr-space-4);
}

.screen__lead {
  margin: 0;
  color: var(--gr-muted-fg);
  font-size: var(--gr-text-sm);
}

.screen__pair {
  display: grid;
  gap: var(--gr-space-4);
}

.screen__switch {
  display: grid;
  gap: var(--gr-space-1);
}

.screen__radio {
  display: grid;
  gap: 0.15em;
}

.screen__hint {
  margin: 0;
  color: var(--gr-muted-fg);
  font-size: var(--gr-text-xs);
}

.screen__danger {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--gr-space-3);
  padding: var(--gr-space-3);
  border: 1px solid var(--gr-danger-border, var(--gr-brd));
  border-radius: var(--gr-radius-md, 8px);
}

.screen__dangerTitle {
  margin: 0;
  font-weight: 600;
}

/* Полоса прилипает к низу: правка может быть сделана в конце длинного
   раздела, и кнопка сохранения не должна остаться выше поля зрения. */
.screen__bar {
  position: sticky;
  bottom: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--gr-space-3);
  padding: var(--gr-space-3);
  border: 1px solid var(--gr-brd);
  border-radius: var(--gr-radius-md, 8px);
  background: var(--gr-card, var(--gr-bg));
  box-shadow: var(--gr-shadow-1);
}

.screen__barActions {
  display: flex;
  gap: var(--gr-space-2);
}

@media (min-width: 768px) {
  .screen {
    padding: var(--gr-space-5);
  }

  .screen__pair {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}
</style>

Собрать такое же