Workspace settings
A settings screen that commits to one save model and says so on the page.
What this proves
Settings pages fail in one specific way more often than any other: half the controls apply instantly and half wait for a button, and nothing on the screen says which is which. The person flips a switch, walks away, and has no way of knowing whether anything happened.
This screen picks one model — nothing applies until you press Save — and prints that sentence under the heading. Change something and the bar appears; while it is on screen, nothing has been saved. That is the whole answer to “did it save”, and it does not require remembering anything.
Instant-apply is not the wrong model. It is the right one for a single toggle in a menu, where the change is one thing and its effect is visible immediately. It stops being right when a page has thirteen controls, because then there is no moment at which the person can change their mind.
Leaving a section takes the changes with it
Tabs are not pages. The browser knows nothing about them, so beforeunload
never fires, and switching sections silently drops whatever was edited. This
screen asks first — because the alternative is not a warning, it is a loss with
no trace.
Switch labels say what the switch does
“Incident alerts” tells you the name of a thing. “Sent immediately, day or night” tells you why you might turn it off. The second sentence is the one that gets read, and leaving it out is how settings end up flipped by people who did not know what they were choosing.
Product news is off by default and the label says it will stay off unless someone opts in. A default is a decision, and writing it down is cheaper than defending it later.
Danger is a section, not a colour
A red button among ordinary ones is an invitation to miss. The destructive actions live in their own tab, which has to be entered on purpose, and the consequence is stated as a count — nine people, twenty-four projects — rather than as the word “permanently”. Numbers are harder to skim past.
Components used
The list is built from frontmatter and checked against the source: a component added to the code and forgotten in the header fails the gate.
GrCarddataGrTabsnavigationGrTabPanelsnavigationGrFormFieldformsGrInputformsGrTextareaformsGrSelectformsGrSwitchformsGrRadioformsGrRadioGroupformsGrButtonactionsGrAlertfeedbackGrConfirmDialogoverlays
Packages:
@feugene/granularity
Entry point
The same file that runs here — examples/settings/App.vue. No second copy of the code exists.
<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>