FIX - align notes editor with Sky controls

Migrates the Notes list and formatting tools to shared Sky controls, restores a neutral glass FAB variant, and gives the note list a dedicated scroll owner so every entry remains reachable.
This commit is contained in:
Type
2026-08-16 13:06:10 +02:00
parent 81cdc8fb87
commit e4fc873bf0
7 changed files with 262 additions and 218 deletions
@@ -10,10 +10,17 @@ const source = readFileSync(
describe('NotesRichTextEditor formatting tabbar', () => {
it('lists every formatting action through shared tabbar primitives', () => {
expect(source.match(/<SkyTabBar\b/g)).toHaveLength(1)
expect(source.match(/<SkyTabButton\b/g)).toHaveLength(11)
expect(source.match(/<SkyTabButton\b/g)).toHaveLength(13)
expect(source).toContain(
"import { SkyIcon, SkyTabBar, SkyTabButton } from '@/ui'",
)
expect(source).toContain(
'<SkyTabBar v-if="editor" :label="labels.toolbar" :labels="false">',
)
expect(source).toContain('<template v-if="!formatMode">')
expect(source).toContain('@click="formatMode = true"')
expect(source).toContain('@click="formatMode = false"')
expect(source).toContain(':disabled="editor.state.selection.empty"')
expect(source).not.toContain('notes-rich-editor__toolbar-row')
expect(source).not.toContain('scrollToolbar')
})
+151 -143
View File
@@ -15,8 +15,9 @@ import {
Strikethrough,
Underline,
Undo2,
X,
} from 'lucide-vue-next'
import { onBeforeUnmount, watch } from 'vue'
import { onBeforeUnmount, ref, watch } from 'vue'
import { SkyIcon, SkyTabBar, SkyTabButton } from '@/ui'
import {
@@ -35,6 +36,7 @@ export type NotesEditorLabels = {
redo: string
strike: string
toolbar: string
closeFormatting: string
underline: string
undo: string
}
@@ -50,6 +52,8 @@ const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const formatMode = ref(false)
const allowedTags = [
'blockquote',
'br',
@@ -231,6 +235,18 @@ function toggleSelectionQuote(): void {
editor.value.view.focus()
}
function hasActiveTextFormat(): boolean {
if (!editor.value) return false
const size = editor.value.getAttributes('noteTextSize').size
return (
Boolean(size) ||
editor.value.isActive('bold') ||
editor.value.isActive('italic') ||
editor.value.isActive('underline') ||
editor.value.isActive('strike')
)
}
watch(
() => props.modelValue,
(body) => {
@@ -256,149 +272,135 @@ onBeforeUnmount(() => editor.value?.destroy())
:editor="editor"
/>
<SkyTabBar v-if="editor" :label="labels.toolbar">
<SkyTabButton
:active="
['tiny', 'small', 'compact'].includes(
editor.getAttributes('noteTextSize').size,
)
"
:aria-label="labels.undo"
:disabled="!editor.can().chain().focus().undo().run()"
@click="editor.chain().focus().undo().run()"
>
<template #icon
><SkyIcon :size="19"><Undo2 /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:active="
['medium', 'large', 'huge'].includes(
editor.getAttributes('noteTextSize').size,
)
"
:aria-label="labels.redo"
:disabled="!editor.can().chain().focus().redo().run()"
@click="editor.chain().focus().redo().run()"
>
<template #icon
><SkyIcon :size="19"><Redo2 /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('bold')"
:class="{
'notes-rich-editor__tool--active': [
'tiny',
'small',
'compact',
].includes(editor.getAttributes('noteTextSize').size),
'notes-rich-editor__tool--unavailable': editor.state.selection.empty,
}"
:aria-label="labels.decreaseText"
@pointerdown.prevent="adjustTextSize(-1)"
>
<span class="notes-rich-editor__text-tool">A</span>
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('italic')"
:class="{
'notes-rich-editor__tool--active': [
'medium',
'large',
'huge',
].includes(editor.getAttributes('noteTextSize').size),
'notes-rich-editor__tool--unavailable': editor.state.selection.empty,
}"
:aria-label="labels.increaseText"
@pointerdown.prevent="adjustTextSize(1)"
>
<span
class="notes-rich-editor__text-tool notes-rich-editor__text-tool--large"
>A+</span
<SkyTabBar v-if="editor" :label="labels.toolbar" :labels="false">
<template v-if="!formatMode">
<SkyTabButton
:aria-label="labels.undo"
:disabled="!editor.can().chain().focus().undo().run()"
@click="editor.chain().focus().undo().run()"
>
</SkyTabButton>
<SkyTabButton
:class="{
'notes-rich-editor__tool--active': editor.isActive('bold'),
}"
:aria-label="labels.bold"
@click="editor.chain().focus().toggleBold().run()"
>
<template #icon
><SkyIcon :size="19"><Bold /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:class="{
'notes-rich-editor__tool--active': editor.isActive('italic'),
}"
:aria-label="labels.italic"
@click="editor.chain().focus().toggleItalic().run()"
>
<template #icon
><SkyIcon :size="19"><Italic /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('underline')"
:class="{
'notes-rich-editor__tool--active': editor.isActive('underline'),
}"
:aria-label="labels.underline"
@click="editor.chain().focus().toggleUnderline().run()"
>
<template #icon
><SkyIcon :size="19"><Underline /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('strike')"
:class="{
'notes-rich-editor__tool--active': editor.isActive('strike'),
}"
:aria-label="labels.strike"
@click="editor.chain().focus().toggleStrike().run()"
>
<template #icon
><SkyIcon :size="19"><Strikethrough /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('bulletList')"
:class="{
'notes-rich-editor__tool--active': editor.isActive('bulletList'),
}"
:aria-label="labels.bulletList"
@click="editor.chain().focus().toggleBulletList().run()"
>
<template #icon
><SkyIcon :size="20"><List /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('orderedList')"
:class="{
'notes-rich-editor__tool--active': editor.isActive('orderedList'),
}"
:aria-label="labels.numberedList"
@click="editor.chain().focus().toggleOrderedList().run()"
>
<template #icon
><SkyIcon :size="20"><ListOrdered /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:class="{
'notes-rich-editor__tool--unavailable': editor.state.selection.empty,
}"
:aria-label="labels.quote"
@pointerdown.prevent="toggleSelectionQuote"
>
<template #icon
><SkyIcon :size="19"><Quote /></SkyIcon
></template>
</SkyTabButton>
<template #icon
><SkyIcon :size="20"><Undo2 /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:aria-label="labels.redo"
:disabled="!editor.can().chain().focus().redo().run()"
@click="editor.chain().focus().redo().run()"
>
<template #icon
><SkyIcon :size="20"><Redo2 /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:active="hasActiveTextFormat()"
:aria-label="labels.toolbar"
@click="formatMode = true"
>
<span class="notes-rich-editor__format-tool">Aa</span>
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('bulletList')"
:aria-label="labels.bulletList"
@click="editor.chain().focus().toggleBulletList().run()"
>
<template #icon
><SkyIcon :size="21"><List /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('orderedList')"
:aria-label="labels.numberedList"
@click="editor.chain().focus().toggleOrderedList().run()"
>
<template #icon
><SkyIcon :size="21"><ListOrdered /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:aria-label="labels.quote"
:disabled="editor.state.selection.empty"
@pointerdown.prevent="toggleSelectionQuote"
>
<template #icon
><SkyIcon :size="20"><Quote /></SkyIcon
></template>
</SkyTabButton>
</template>
<template v-else>
<SkyTabButton
:active="
['tiny', 'small', 'compact'].includes(
editor.getAttributes('noteTextSize').size,
)
"
:aria-label="labels.decreaseText"
:disabled="editor.state.selection.empty"
@pointerdown.prevent="adjustTextSize(-1)"
>
<span class="notes-rich-editor__text-tool">A</span>
</SkyTabButton>
<SkyTabButton
:active="
['medium', 'large', 'huge'].includes(
editor.getAttributes('noteTextSize').size,
)
"
:aria-label="labels.increaseText"
:disabled="editor.state.selection.empty"
@pointerdown.prevent="adjustTextSize(1)"
>
<span
class="notes-rich-editor__text-tool notes-rich-editor__text-tool--large"
>A+</span
>
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('bold')"
:aria-label="labels.bold"
@click="editor.chain().focus().toggleBold().run()"
>
<template #icon
><SkyIcon :size="20"><Bold /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('italic')"
:aria-label="labels.italic"
@click="editor.chain().focus().toggleItalic().run()"
>
<template #icon
><SkyIcon :size="20"><Italic /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('underline')"
:aria-label="labels.underline"
@click="editor.chain().focus().toggleUnderline().run()"
>
<template #icon
><SkyIcon :size="20"><Underline /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('strike')"
:aria-label="labels.strike"
@click="editor.chain().focus().toggleStrike().run()"
>
<template #icon
><SkyIcon :size="20"><Strikethrough /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:aria-label="labels.closeFormatting"
@click="formatMode = false"
>
<template #icon
><SkyIcon :size="20"><X /></SkyIcon
></template>
</SkyTabButton>
</template>
</SkyTabBar>
</section>
</template>
@@ -543,4 +545,10 @@ onBeforeUnmount(() => editor.value?.destroy())
.notes-rich-editor__text-tool--large {
font-size: 17px;
}
.notes-rich-editor__format-tool {
font-size: 17px;
font-weight: 500;
letter-spacing: -0.03em;
}
</style>
+15
View File
@@ -2724,10 +2724,25 @@ label.sky-list-item__row {
padding: 0;
}
.sky-glass.sky-fab--neutral {
background: var(--sky-glass-solid, rgba(247, 247, 248, 0.96));
color: var(--sky-text, #000000);
}
.sky-fab--neutral .sky-fab__accent-layer,
.sky-fab--neutral .sky-fab__dark-accent-layer,
.sky-fab--neutral .sky-fab__surface-layer {
display: none;
}
.sky-glass.sky-fab:active:not(:disabled) {
background: transparent;
}
.sky-glass.sky-fab--neutral:active:not(:disabled) {
background: var(--sky-glass-solid, rgba(247, 247, 248, 0.96));
}
.sky-fab--disabled {
cursor: default;
opacity: 0.42;
+18
View File
@@ -55,4 +55,22 @@ describe('SkyFab', () => {
expect(fabTokens).toContain('--sky-fab-accent-inset-start')
expect(fabTokens).not.toContain('rgba(10, 132, 255, 0.25)')
})
it('offers a neutral glass variant without accent layers', async () => {
const html = await renderToString(
createSSRApp({
render: () => h(SkyFab, { ariaLabel: 'Create', variant: 'neutral' }),
}),
)
expect(html).toContain('sky-fab--neutral')
const controls = readFileSync(
fileURLToPath(new URL('../controls.css', import.meta.url)),
'utf8',
)
expect(controls).toMatch(
/\.sky-glass\.sky-fab--neutral\s*\{[^}]*background:\s*var\(--sky-glass-solid/s,
)
})
})
+3
View File
@@ -14,6 +14,7 @@ const props = withDefaults(
text?: string
textPosition?: 'after' | 'before'
type?: 'button' | 'reset' | 'submit'
variant?: 'neutral' | 'primary'
}>(),
{
ariaLabel: '',
@@ -23,6 +24,7 @@ const props = withDefaults(
text: '',
textPosition: 'after',
type: 'button',
variant: 'primary',
},
)
@@ -71,6 +73,7 @@ function handleClick(event: MouseEvent): void {
:class="{
'sky-fab--disabled': disabled,
'sky-fab--icon-only': !hasText,
'sky-fab--neutral': variant === 'neutral',
'sky-fab--with-text': hasText,
}"
role="button"
@@ -15,16 +15,22 @@ const listSource = source.slice(
describe('NotesApp list controls', () => {
it('places the Sky searchbar and create action together at the bottom', () => {
const composerSource = listSource.slice(
listSource.indexOf('<footer'),
listSource.indexOf('</footer>') + '</footer>'.length,
listSource.indexOf('<SkyToolbar'),
listSource.indexOf('</SkyToolbar>') + '</SkyToolbar>'.length,
)
expect(composerSource).toContain('component="footer"')
expect(listSource).toContain('<SkyScrollArea as="main"')
expect(composerSource).toContain('<SkySearchbar')
expect(composerSource).toContain('v-model="searchQuery"')
expect(composerSource).toContain('<SkyFab')
expect(composerSource).toContain('variant="neutral"')
expect(composerSource).toContain('@click="createNote"')
expect(composerSource).not.toContain('notes-search')
expect(composerSource).not.toContain('notes-create-fab')
expect(listSource).not.toContain('<k-searchbar')
expect(listSource).not.toContain('<template #right>')
expect(listSource).not.toContain('!pt-[44px]')
})
})
+59 -72
View File
@@ -24,7 +24,14 @@ import NotesRichTextEditor from '@/components/NotesRichTextEditor.vue'
import { useNotesStore } from '@/stores/notes'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePhoneStore } from '@/stores/phone'
import { SkyActionSheet, SkyButton, SkyFab, SkySearchbar } from '@/ui'
import {
SkyActionSheet,
SkyButton,
SkyFab,
SkyScrollArea,
SkySearchbar,
SkyToolbar,
} from '@/ui'
import type { Note } from '@/utils/notes'
import { noteBodyToPlainText } from '@/utils/noteRichText'
@@ -60,6 +67,7 @@ const visibleNotes = computed(() => {
const editorLabels = computed(() => ({
bold: phone.t('Apps.notes.tools.bold'),
bulletList: phone.t('Apps.notes.tools.bulletList'),
closeFormatting: phone.t('Common.close'),
decreaseText: phone.t('Apps.notes.tools.decreaseText'),
increaseText: phone.t('Apps.notes.tools.increaseText'),
italic: phone.t('Apps.notes.tools.italic'),
@@ -116,7 +124,10 @@ function editNote(note: Note): void {
function persistDraft(): Note | undefined {
const draft = {
body: draftBody.value,
title: titleFromDraftBody(draftBody.value) || currentNote.value?.title.trim() || '',
title:
titleFromDraftBody(draftBody.value) ||
currentNote.value?.title.trim() ||
'',
}
if (editorId.value) {
@@ -174,65 +185,70 @@ function shareNote(): void {
<template>
<k-page
v-if="!editorOpened"
class="notes-list-page !pt-[44px]"
class="notes-list-page"
:aria-label="phone.t('Apps.notes.name')"
>
<k-navbar large transparent :title="phone.t('Apps.notes.name')" />
<k-list v-if="visibleNotes.length" strong inset>
<k-list-item
v-for="note in visibleNotes"
:key="note.id"
href="#"
:title="noteTitle(note)"
:subtitle="noteSubtitle(note)"
:chevron="false"
strong-title="auto"
@click.prevent="editNote(note)"
>
<template v-if="note.pinned" #after>
<Pin :size="15" aria-hidden="true" />
</template>
</k-list-item>
</k-list>
<template v-else>
<k-block-title large>{{
phone.t(searchQuery ? 'Apps.notes.noResults' : 'Apps.notes.emptyTitle')
}}</k-block-title>
<k-block strong inset>{{
phone.t(
searchQuery ? 'Apps.notes.noResultsBody' : 'Apps.notes.emptyBody',
)
}}</k-block>
<k-list v-if="!searchQuery" strong inset>
<k-list-button link-component="button" @click="createNote">
{{ phone.t('Apps.notes.newNote') }}
</k-list-button>
<SkyScrollArea as="main" class="notes-list-scroll">
<k-list v-if="visibleNotes.length" strong inset>
<k-list-item
v-for="note in visibleNotes"
:key="note.id"
href="#"
:title="noteTitle(note)"
:subtitle="noteSubtitle(note)"
:chevron="false"
strong-title="auto"
@click.prevent="editNote(note)"
>
<template v-if="note.pinned" #after>
<Pin :size="15" aria-hidden="true" />
</template>
</k-list-item>
</k-list>
</template>
<footer
<template v-else>
<k-block-title large>{{
phone.t(
searchQuery ? 'Apps.notes.noResults' : 'Apps.notes.emptyTitle',
)
}}</k-block-title>
<k-block strong inset>{{
phone.t(
searchQuery ? 'Apps.notes.noResultsBody' : 'Apps.notes.emptyBody',
)
}}</k-block>
<k-list v-if="!searchQuery" strong inset>
<k-list-button link-component="button" @click="createNote">
{{ phone.t('Apps.notes.newNote') }}
</k-list-button>
</k-list>
</template>
</SkyScrollArea>
<SkyToolbar
class="notes-composer sky-ui-provider"
:class="{ 'sky-ui-provider--dark': phone.isDarkMode }"
component="footer"
:aria-label="phone.t('Apps.notes.searchPlaceholder')"
>
<SkySearchbar
v-model="searchQuery"
class="notes-search"
:clear-label="phone.t('Common.clear')"
:label="phone.t('Apps.notes.searchPlaceholder')"
:placeholder="phone.t('Apps.notes.searchPlaceholder')"
/>
<SkyFab
class="notes-create-fab"
:aria-label="phone.t('Apps.notes.newNote')"
variant="neutral"
@click="createNote"
>
<template #icon>
<SquarePen :size="21" aria-hidden="true" />
</template>
</SkyFab>
</footer>
</SkyToolbar>
</k-page>
<k-page v-else class="notes-editor-page !pt-[44px] !pb-0">
@@ -281,11 +297,7 @@ function shareNote(): void {
{{ phone.t('Apps.easyShare.name') }}
</SkyButton>
<SkyButton block large tonal @click="togglePinned">
<PinOff
v-if="currentNote?.pinned"
:size="19"
aria-hidden="true"
/>
<PinOff v-if="currentNote?.pinned" :size="19" aria-hidden="true" />
<Pin v-else :size="19" aria-hidden="true" />
{{
phone.t(
@@ -314,35 +326,10 @@ function shareNote(): void {
<style scoped>
.notes-list-page {
padding-bottom: calc(
var(--sky-safe-area-bottom) + var(--sky-touch-target) + 24px
) !important;
}
.notes-composer {
position: absolute;
z-index: 20;
right: 0;
bottom: 0;
left: 0;
min-width: 0;
padding: 8px calc(var(--sky-page-gutter) + var(--sky-safe-area-right))
calc(var(--sky-safe-area-bottom) + 8px)
calc(var(--sky-page-gutter) + var(--sky-safe-area-left));
display: grid;
grid-template-columns: minmax(0, 1fr) var(--sky-touch-target);
align-items: center;
gap: 10px;
background: linear-gradient(to top, var(--sky-bg) 72%, transparent);
}
.notes-search {
min-width: 0;
}
.notes-create-fab {
width: var(--sky-touch-target);
height: var(--sky-touch-target);
padding-bottom: 0 !important;
display: flex;
flex-direction: column;
overflow: hidden;
}
.notes-editor-page {