Настройки пространства
Настройки, у которых одна модель сохранения — и она написана на самой странице.
Что он доказывает
Страницы настроек ломаются одним способом чаще, чем всеми остальными: часть элементов срабатывает сразу, часть ждёт кнопки, и на экране не написано, что из чего. Человек трогает переключатель, уходит и не знает, случилось ли что-нибудь.
Этот экран выбирает одну модель — ничего не применяется, пока не нажать «Сохранить», — и печатает эту фразу под заголовком. Поменяйте что-нибудь, и появится полоса; пока она на экране, не сохранено ничего. Это и есть весь ответ на вопрос «сохранилось ли», и он не требует ничего помнить.
Мгновенное применение — не ошибочная модель. Она верна для одного переключателя в меню, где изменение одно и его следствие видно сразу. Она перестаёт быть верной, когда на странице тринадцать элементов: тогда у человека не остаётся мгновения, в которое можно передумать.
Уход из раздела уносит правки
Вкладки — не страницы. Браузер о них не знает, beforeunload не сработает, и
переход в соседний раздел молча теряет всё, что было поменяно. Экран
спрашивает, потому что иначе это не предупреждение, а потеря без следа.
Подпись переключателя говорит, что он делает
«Оповещения об инцидентах» называет вещь. «Уходят сразу, днём и ночью» объясняет, почему их выключают. Читают вторую фразу, и её отсутствие — то, из-за чего настройки меняют люди, не понявшие, что выбирают.
Новости продукта выключены по умолчанию, и в подписи сказано, что они такими и останутся, пока не включат. Значение по умолчанию — это решение, и записать его дешевле, чем потом защищать.
Опасное — это раздел, а не цвет
Красная кнопка среди обычных — приглашение промахнуться. Разрушительные действия живут в своей вкладке, куда надо зайти нарочно, а последствие названо числом — девять человек, двадцать четыре проекта, — а не словом «безвозвратно». Числа труднее пролистать глазами.
Использованные компоненты
Список строится из frontmatter и сверяется с исходником: компонент, добавленный в код и забытый в шапке, роняет гейт.
GrCardДанныеGrTabsНавигацияGrTabPanelsНавигацияGrFormFieldФормыGrInputФормыGrTextareaФормыGrSelectФормыGrSwitchФормыGrRadioФормыGrRadioGroupФормыGrButtonДействияGrAlertОбратная связьGrConfirmDialogСлои
Пакеты:
@feugene/granularity
Точка входа
Тот же файл, который здесь исполняется, — 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>