ENH - improve Weazel search and article media

Load the newest public articles before a search, show the shared empty state for real zero-result queries, and guard debounced requests across tab changes. Add accessible multi-image navigation and move composer category and status choices to the central SkyDropdown.
This commit is contained in:
Dominik
2026-08-16 20:33:50 +02:00
parent 3b7f0b8858
commit e062ed083f
6 changed files with 499 additions and 71 deletions
+3
View File
@@ -594,6 +594,9 @@ const defaultLocales: LocaleTree = {
search: 'Search Weazel News',
clearSearch: 'Clear article search',
articleImages: 'Article photos',
previousPhoto: 'Show the previous article photo',
nextPhoto: 'Show the next article photo',
showPhoto: 'Show article photo {current} of {total}',
coverPreview: 'Article cover preview',
removeCover: 'Remove the selected cover image',
removePhoto: 'Remove this article photo',
+31
View File
@@ -115,6 +115,37 @@ describe('Weazel News store', () => {
expect(store.publicHasMore).toBe(false)
})
it('normalizes the public response to newest first without changing images', async () => {
const older = {
...article,
id: 'older-article',
publishedAt: (article.publishedAt ?? 0) - 1_000,
}
const newer = {
...article,
id: 'newer-article',
images: [...article.images].reverse(),
publishedAt: (article.publishedAt ?? 0) + 1_000,
}
mockNuiCall.mockResolvedValueOnce({
data: { hasMore: false, items: [older, newer] },
success: true,
})
const store = useWeazelNewsStore()
expect(await store.loadPublic()).toBe(true)
expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:list', {
category: null,
offset: 0,
search: '',
})
expect(store.publicItems.map((item) => item.id)).toEqual([
'newer-article',
'older-article',
])
expect(store.publicItems[0]?.images).toEqual(newer.images)
})
it('replaces stale search results with a successful empty response', async () => {
mockNuiCall.mockResolvedValueOnce({
data: { hasMore: false, items: [] },
+19 -10
View File
@@ -38,6 +38,16 @@ function mergeArticles(
return [...merged.values()]
}
function sortNewestFirst(
items: WeazelNewsArticleSummary[],
): WeazelNewsArticleSummary[] {
return [...items].sort(
(left, right) =>
(right.publishedAt ?? right.updatedAt ?? right.createdAt) -
(left.publishedAt ?? left.updatedAt ?? left.createdAt),
)
}
export const useWeazelNewsStore = defineStore('weazel-news', {
state: () => ({
context: null as WeazelNewsContext | null,
@@ -106,9 +116,11 @@ export const useWeazelNewsStore = defineStore('weazel-news', {
this.error = this.publicError
return false
}
this.publicItems = append
? mergeArticles(this.publicItems, response.data.items)
: response.data.items
this.publicItems = sortNewestFirst(
append
? mergeArticles(this.publicItems, response.data.items)
: response.data.items,
)
this.publicHasMore = response.data.hasMore
this.publicError = ''
this.error = ''
@@ -202,10 +214,10 @@ export const useWeazelNewsStore = defineStore('weazel-news', {
...this.managedItems.filter((item) => item.id !== article.id),
]
if (article.status === 'published') {
this.publicItems = [
this.publicItems = sortNewestFirst([
article,
...this.publicItems.filter((item) => item.id !== article.id),
]
])
}
this.error = ''
return article
@@ -240,13 +252,10 @@ export const useWeazelNewsStore = defineStore('weazel-news', {
this.managedItems = replaceArticle(this.managedItems, canonical)
this.publicItems =
canonical.status === 'published'
? [
? sortNewestFirst([
canonical,
...this.publicItems.filter((item) => item.id !== canonical.id),
].sort(
(left, right) =>
(right.publishedAt ?? 0) - (left.publishedAt ?? 0),
)
])
: this.publicItems.filter((item) => item.id !== canonical.id)
this.error = ''
return canonical
@@ -132,10 +132,36 @@ describe('Weazel News article detail contract', () => {
/@click\s*=\s*["']\s*editArticle\s*\(\s*selectedArticle\s*\)\s*["']/,
)
})
it('provides explicit controls and touch gestures for multiple article images', () => {
const paginationButtonRule =
source.match(/\.weazel-detail-pagination button\s*\{[^}]*\}/s)?.[0] ?? ''
expect(detailSource).toContain(':key="activeDetailImage.id"')
expect(detailSource).toContain('role="group"')
expect(detailSource).toContain('@click="showPreviousDetailImage"')
expect(detailSource).toContain('@click="showNextDetailImage"')
expect(detailSource).toContain('@click="selectDetailImage(index)"')
expect(detailSource).toContain(
'@keydown.left.prevent="showPreviousDetailImage"',
)
expect(detailSource).toContain(
'@keydown.right.prevent="showNextDetailImage"',
)
expect(detailSource).toContain(
'@touchstart.passive="beginDetailImageSwipe"',
)
expect(detailSource).toContain('@touchend.passive="finishDetailImageSwipe"')
expect(detailSource).toContain("t('accessibility.previousPhoto')")
expect(detailSource).toContain("t('accessibility.nextPhoto')")
expect(paginationButtonRule).toContain('width: var(--sky-touch-target)')
expect(paginationButtonRule).toContain('height: var(--sky-touch-target)')
})
})
describe('Weazel News empty search contract', () => {
it('uses the shared compact empty state when no article matches', () => {
const clearButtonRule = styleRule('.weazel-search-clear')
const emptyStateTag = searchSource.match(
/<(?:SkyEmptyState|sky-empty-state)\b[^>]*>/is,
)?.[0]
@@ -143,24 +169,64 @@ describe('Weazel News empty search contract', () => {
expect(searchSource).not.toBe('')
expect(emptyStateTag).toBeDefined()
expect(emptyStateTag).toMatch(/\bcompact\b/i)
expect(emptyStateTag).toMatch(/!\s*news\.publicItems\.length/)
expect(emptyStateTag).toMatch(
/searchSubmitted\s*&&\s*!\s*news\.publicItems\.length/,
)
expect(emptyStateTag).toContain("t('search.emptyTitle')")
expect(emptyStateTag).toContain("t('search.emptyBody')")
expect(clearButtonRule).toContain('width: var(--sky-touch-target)')
expect(clearButtonRule).toContain('height: var(--sky-touch-target)')
})
it('loads and lists the latest articles before a query is submitted', () => {
expect(source).toContain("await news.loadPublic({ search: '' })")
expect(source).toContain("if (tab !== 'search') cancelQueuedSearch()")
expect(source).toContain("if (activeTab.value !== 'search') return")
expect(searchSource).toContain('<div v-else class="weazel-card-list">')
expect(searchSource).toMatch(
/v-for\s*=\s*["']article\s+in\s+news\.publicItems["']/,
)
expect(searchSource).not.toContain(
'v-else-if="searchSubmitted" class="weazel-card-list"',
)
})
})
describe('Weazel News article composer contract', () => {
it('uses the central SkyField select for the article category', () => {
const categorySelect = openingTags('SkyField', 'sky-field').find(
(tag) =>
/\btype\s*=\s*["']select["']/i.test(tag) && /draft\.category/.test(tag),
it('uses checked central SkyDropdown menus for category and status', () => {
const categoryTrigger = openingTags(
'SkyListItem',
'sky-list-item',
composerSource,
).find((tag) => /weazel-news-category-trigger/.test(tag))
const statusTrigger = openingTags(
'SkyListItem',
'sky-list-item',
composerSource,
).find((tag) => /weazel-news-status-trigger/.test(tag))
const dropdowns = openingTags('SkyDropdown', 'sky-dropdown', composerSource)
const categoryDropdown = dropdowns.find((tag) =>
/categoryDropdownItems/.test(tag),
)
const statusDropdown = dropdowns.find((tag) =>
/statusDropdownItems/.test(tag),
)
expect(categorySelect).toBeDefined()
expect(categorySelect).toMatch(/:options\s*=/i)
expect(categorySelect).toMatch(/\bdropdown\b/i)
expect(categorySelect).toContain("t('composer.category')")
expect(composerSource).not.toContain("composerChoice = 'category'")
expect(categoryTrigger).toBeDefined()
expect(categoryTrigger).toContain("'aria-expanded': categoryDropdownOpened")
expect(categoryTrigger).toContain("'aria-haspopup': 'menu'")
expect(statusTrigger).toBeDefined()
expect(statusTrigger).toContain("'aria-expanded': statusDropdownOpened")
expect(statusTrigger).toContain("'aria-haspopup': 'menu'")
expect(categoryDropdown).toBeDefined()
expect(categoryDropdown).toContain(':opened="categoryDropdownOpened"')
expect(statusDropdown).toBeDefined()
expect(statusDropdown).toContain(':opened="statusDropdownOpened"')
expect(source).toContain('checked: draft.value.category === category')
expect(source).toContain("checked: draft.value.status === 'draft'")
expect(composerSource).not.toMatch(
/<(?:SkyField|sky-field)\b[^>]*\btype\s*=\s*["']select["']/i,
)
})
it('keeps Photos and Camera directly visible in the composer', () => {
+367 -51
View File
@@ -5,6 +5,7 @@ import {
SkyCard,
SkyDialog,
SkyDialogButton,
SkyDropdown,
SkyEmptyState,
SkyField,
SkyIcon,
@@ -25,6 +26,8 @@ import {
Building2,
Camera,
CalendarDays,
ChevronDown,
ChevronLeft,
ChevronRight,
FileText,
House,
@@ -57,7 +60,6 @@ import { WEAZEL_NEWS_CATEGORY_IDS } from '@/types/weazel-news'
type MainTab = 'home' | 'categories' | 'search' | 'editorial'
type Screen = 'main' | 'detail' | 'composer'
type ComposerOption = { label: string; value: string }
type SelectedImage = { id: number; url: string }
type ComposerContext = {
article: WeazelNewsArticle | null
@@ -83,6 +85,11 @@ const searchPending = ref(false)
const editorialStatus = ref<WeazelNewsManageStatus>('all')
const editingArticle = ref<WeazelNewsArticle | null>(null)
const selectedImages = ref<SelectedImage[]>([])
const detailImageIndex = ref(0)
const categoryDropdownOpened = ref(false)
const categoryDropdownTarget = ref<HTMLElement | null>(null)
const statusDropdownOpened = ref(false)
const statusDropdownTarget = ref<HTMLElement | null>(null)
const deleteTarget = ref<WeazelNewsArticle | null>(null)
const deleteDialogOpened = ref(false)
const toastOpened = ref(false)
@@ -119,15 +126,24 @@ const activeTabIndex = computed(() =>
activeTab.value,
),
)
const categoryOptions = computed<ComposerOption[]>(() =>
const categoryDropdownItems = computed(() =>
WEAZEL_NEWS_CATEGORY_IDS.map((category) => ({
checked: draft.value.category === category,
id: category,
label: categoryLabel(category),
value: category,
})),
)
const statusOptions = computed<ComposerOption[]>(() => [
{ label: t('composer.statusDraft'), value: 'draft' },
{ label: t('composer.statusPublished'), value: 'published' },
const statusDropdownItems = computed(() => [
{
checked: draft.value.status === 'draft',
id: 'draft',
label: t('composer.statusDraft'),
},
{
checked: draft.value.status === 'published',
id: 'published',
label: t('composer.statusPublished'),
},
])
const detailImages = computed<SelectedImage[]>(() => {
const article = selectedArticle.value
@@ -142,6 +158,10 @@ const detailImages = computed<SelectedImage[]>(() => {
? [{ id: article.imageMediaId, url: article.imageUrl }]
: []
})
const activeDetailImage = computed(
() =>
detailImages.value[detailImageIndex.value] ?? detailImages.value[0] ?? null,
)
const knownErrors = new Set([
'feature_disabled',
'invalid_article',
@@ -177,7 +197,7 @@ const currentSurfaceLoading = computed(() => {
}
if (activeTab.value === 'categories') return news.contextLoading
if (activeTab.value === 'search') {
return searchSubmitted.value && (searchPending.value || news.publicLoading)
return searchPending.value || news.publicLoading
}
return news.publicLoading
})
@@ -236,14 +256,26 @@ async function loadHome(): Promise<void> {
await news.loadPublic({ category: selectedCategory.value })
}
function cancelQueuedSearch(): void {
if (searchTimer !== undefined) {
window.clearTimeout(searchTimer)
searchTimer = undefined
}
searchPending.value = false
}
async function selectTab(tab: MainTab): Promise<void> {
if (tab !== 'search') cancelQueuedSearch()
activeTab.value = tab
screen.value = 'main'
news.error = ''
if (tab === 'home') await loadHome()
if (tab === 'categories') await news.loadContext()
if (tab === 'search' && searchSubmitted.value) await submitSearch()
if (tab === 'search') {
if (searchSubmitted.value) await submitSearch()
else await loadLatestSearch()
}
if (tab === 'editorial') await loadEditorial()
}
@@ -268,7 +300,7 @@ async function submitSearch(): Promise<void> {
}
submittedSearchQuery.value = searchQuery.value.trim()
if (!submittedSearchQuery.value) {
clearSearch()
await clearSearch()
return
}
searchSubmitted.value = true
@@ -280,7 +312,7 @@ function queueSearch(event: Event): void {
searchQuery.value = eventValue(event)
if (searchTimer !== undefined) window.clearTimeout(searchTimer)
if (!searchQuery.value.trim()) {
clearSearch()
void clearSearch()
return
}
submittedSearchQuery.value = searchQuery.value.trim()
@@ -288,6 +320,7 @@ function queueSearch(event: Event): void {
searchPending.value = true
searchTimer = window.setTimeout(() => {
searchTimer = undefined
if (activeTab.value !== 'search') return
void submitSearch()
}, 250)
}
@@ -300,7 +333,16 @@ async function loadMoreSearch(): Promise<void> {
})
}
function clearSearch(): void {
async function loadLatestSearch(): Promise<void> {
await news.loadPublic({ search: '' })
}
async function retrySearch(): Promise<void> {
if (searchSubmitted.value) await submitSearch()
else await loadLatestSearch()
}
async function clearSearch(): Promise<void> {
if (searchTimer !== undefined) {
window.clearTimeout(searchTimer)
searchTimer = undefined
@@ -311,6 +353,7 @@ function clearSearch(): void {
searchPending.value = false
news.publicError = ''
news.error = ''
await loadLatestSearch()
}
async function openArticle(
@@ -321,12 +364,14 @@ async function openArticle(
showToast(errorKey(news.detailError))
return
}
detailImageIndex.value = 0
detailManaged.value = managed
screen.value = 'detail'
}
function closeDetail(): void {
screen.value = 'main'
detailImageIndex.value = 0
news.selected = null
if (activeTab.value === 'editorial' && news.context?.canManage) {
void news.loadManaged(editorialStatus.value)
@@ -337,6 +382,7 @@ function createArticle(): void {
if (!news.context?.canManage) return
editingArticle.value = null
selectedImages.value = []
closeComposerDropdowns()
draft.value = emptyDraft()
screen.value = 'composer'
}
@@ -344,6 +390,7 @@ function createArticle(): void {
function editArticle(article: WeazelNewsArticle): void {
if (!news.context?.canManage) return
editingArticle.value = article
closeComposerDropdowns()
selectedImages.value = article.images?.length
? article.images.map((image) => ({
id: image.mediaId,
@@ -363,9 +410,29 @@ function editArticle(article: WeazelNewsArticle): void {
}
function closeComposer(): void {
closeComposerDropdowns()
screen.value = editingArticle.value ? 'detail' : 'main'
}
function closeComposerDropdowns(): void {
categoryDropdownOpened.value = false
statusDropdownOpened.value = false
}
function toggleCategoryDropdown(event: MouseEvent): void {
if (!(event.currentTarget instanceof HTMLElement)) return
categoryDropdownTarget.value = event.currentTarget
statusDropdownOpened.value = false
categoryDropdownOpened.value = !categoryDropdownOpened.value
}
function toggleStatusDropdown(event: MouseEvent): void {
if (!(event.currentTarget instanceof HTMLElement)) return
statusDropdownTarget.value = event.currentTarget
categoryDropdownOpened.value = false
statusDropdownOpened.value = !statusDropdownOpened.value
}
function updateCategory(value: string): void {
if (WEAZEL_NEWS_CATEGORY_IDS.includes(value as WeazelNewsCategoryId)) {
draft.value.category = value as WeazelNewsCategoryId
@@ -378,6 +445,53 @@ function updateStatus(value: string): void {
}
}
function selectCategoryDropdownItem(id: string): void {
updateCategory(id)
closeComposerDropdowns()
}
function selectStatusDropdownItem(id: string): void {
updateStatus(id)
closeComposerDropdowns()
}
function selectDetailImage(index: number): void {
if (index < 0 || index >= detailImages.value.length) return
detailImageIndex.value = index
}
function showPreviousDetailImage(): void {
const count = detailImages.value.length
if (count < 2) return
detailImageIndex.value = (detailImageIndex.value - 1 + count) % count
}
function showNextDetailImage(): void {
const count = detailImages.value.length
if (count < 2) return
detailImageIndex.value = (detailImageIndex.value + 1) % count
}
let detailSwipeStartX: number | null = null
function beginDetailImageSwipe(event: TouchEvent): void {
detailSwipeStartX = event.changedTouches.item(0)?.clientX ?? null
}
function finishDetailImageSwipe(event: TouchEvent): void {
const endX = event.changedTouches.item(0)?.clientX
if (detailSwipeStartX === null || endX === undefined) return
const distance = endX - detailSwipeStartX
detailSwipeStartX = null
if (Math.abs(distance) < 44) return
if (distance < 0) showNextDetailImage()
else showPreviousDetailImage()
}
function cancelDetailImageSwipe(): void {
detailSwipeStartX = null
}
function syncImageMediaIds(): void {
draft.value.imageMediaIds = selectedImages.value.map((image) => image.id)
}
@@ -462,6 +576,7 @@ async function saveArticle(
editingArticle.value = article
news.selected = article
detailImageIndex.value = 0
detailManaged.value = true
screen.value = 'detail'
showToast(wasEditing ? 'feedback.updated' : 'feedback.created')
@@ -541,6 +656,8 @@ async function initialize(): Promise<void> {
onMounted(() => void initialize())
onBeforeUnmount(() => {
categoryDropdownTarget.value = null
statusDropdownTarget.value = null
if (toastTimer !== undefined) window.clearTimeout(toastTimer)
if (searchTimer !== undefined) window.clearTimeout(searchTimer)
})
@@ -769,7 +886,7 @@ onBeforeUnmount(() => {
<Newspaper :size="34" />
<strong>{{ t('states.errorTitle') }}</strong>
<span>{{ contextualError }}</span>
<sky-button rounded small @click="submitSearch">{{
<sky-button rounded small @click="retrySearch">{{
t('retry')
}}</sky-button>
</div>
@@ -781,7 +898,15 @@ onBeforeUnmount(() => {
>
<template #icon><Search :size="34" /></template>
</sky-empty-state>
<div v-else-if="searchSubmitted" class="weazel-card-list">
<div
v-else-if="!news.publicItems.length"
class="weazel-state weazel-state--compact"
>
<FileText :size="34" />
<strong>{{ t('states.emptyTitle') }}</strong>
<span>{{ t('states.emptyBody') }}</span>
</div>
<div v-else class="weazel-card-list">
<sky-card
v-for="article in news.publicItems"
:key="article.id"
@@ -801,9 +926,7 @@ onBeforeUnmount(() => {
</sky-card>
</div>
<sky-button
v-if="
searchSubmitted && news.publicHasMore && !currentSurfaceLoading
"
v-if="news.publicHasMore && !currentSurfaceLoading"
class="weazel-load-more"
tonal
rounded
@@ -1046,25 +1169,77 @@ onBeforeUnmount(() => {
<div
v-if="detailImages.length"
class="weazel-detail-gallery"
role="group"
:aria-label="t('accessibility.articleImages')"
:tabindex="detailImages.length > 1 ? 0 : undefined"
@keydown.left.prevent="showPreviousDetailImage"
@keydown.right.prevent="showNextDetailImage"
@touchstart.passive="beginDetailImageSwipe"
@touchend.passive="finishDetailImageSwipe"
@touchcancel="cancelDetailImageSwipe"
>
<img
v-for="image in detailImages"
:key="image.id"
v-if="activeDetailImage"
:key="activeDetailImage.id"
class="weazel-detail-cover"
:src="image.url"
:src="activeDetailImage.url"
:alt="
t('article.coverAlt', {
title: selectedArticle.title,
})
"
/>
<template v-if="detailImages.length > 1">
<sky-button
icon-only
rounded
tonal
class="weazel-detail-gallery-control is-previous"
:aria-label="t('accessibility.previousPhoto')"
@click="showPreviousDetailImage"
>
<ChevronLeft :size="22" />
</sky-button>
<sky-button
icon-only
rounded
tonal
class="weazel-detail-gallery-control is-next"
:aria-label="t('accessibility.nextPhoto')"
@click="showNextDetailImage"
>
<ChevronRight :size="22" />
</sky-button>
<div
class="weazel-detail-pagination"
role="group"
:aria-label="t('accessibility.articleImages')"
>
<button
v-for="(image, index) in detailImages"
:key="image.id"
type="button"
:class="{ 'is-active': detailImageIndex === index }"
:aria-current="detailImageIndex === index ? 'true' : undefined"
:aria-label="
t('accessibility.showPhoto', {
current: String(index + 1),
total: String(detailImages.length),
})
"
@click="selectDetailImage(index)"
>
<span></span>
</button>
</div>
</template>
<span
v-if="detailImages.length > 1"
class="weazel-detail-count"
aria-hidden="true"
>
<Images :size="13" /> {{ detailImages.length }}
<Images :size="13" /> {{ detailImageIndex + 1 }} /
{{ detailImages.length }}
</span>
</div>
<div v-else class="weazel-detail-masthead">
@@ -1210,23 +1385,55 @@ onBeforeUnmount(() => {
:value="draft.body"
@input="draft.body = eventValue($event)"
/>
<sky-field
type="select"
dropdown
:label="t('composer.category')"
:options="categoryOptions"
:model-value="draft.category"
@update:model-value="updateCategory"
/>
<sky-field
<sky-list-item
link
link-component="button"
:chevron="false"
:title="t('composer.category')"
:aria-label="`${t('composer.category')}: ${categoryLabel(draft.category)}`"
:link-props="{
id: 'weazel-news-category-trigger',
'aria-controls': 'weazel-news-category-dropdown',
'aria-expanded': categoryDropdownOpened,
'aria-haspopup': 'menu',
type: 'button',
}"
@click="toggleCategoryDropdown"
>
<template #after>
<span class="weazel-composer-select-value">
{{ categoryLabel(draft.category) }}
<ChevronDown :size="17" aria-hidden="true" />
</span>
</template>
</sky-list-item>
<sky-list-item
v-if="editingArticle"
type="select"
dropdown
:label="t('composer.status')"
:options="statusOptions"
:model-value="draft.status"
@update:model-value="updateStatus"
/>
link
link-component="button"
:chevron="false"
:title="t('composer.status')"
:aria-label="
t('accessibility.status', {
status: statusLabel(draft.status),
})
"
:link-props="{
id: 'weazel-news-status-trigger',
'aria-controls': 'weazel-news-status-dropdown',
'aria-expanded': statusDropdownOpened,
'aria-haspopup': 'menu',
type: 'button',
}"
@click="toggleStatusDropdown"
>
<template #after>
<span class="weazel-composer-select-value">
{{ statusLabel(draft.status) }}
<ChevronDown :size="17" aria-hidden="true" />
</span>
</template>
</sky-list-item>
</sky-list>
<div class="weazel-composer-actions">
@@ -1262,6 +1469,35 @@ onBeforeUnmount(() => {
</sky-scroll-area>
</template>
<sky-dropdown
id="weazel-news-category-dropdown"
class="weazel-composer-dropdown sky-ui-provider"
:class="{ 'sky-ui-provider--dark': phone.isDarkMode }"
:items="categoryDropdownItems"
:label="t('composer.category')"
:opened="categoryDropdownOpened"
placement="auto"
:target="categoryDropdownTarget"
@backdropclick="closeComposerDropdowns"
@escape="closeComposerDropdowns"
@positionerror="closeComposerDropdowns"
@select="selectCategoryDropdownItem"
/>
<sky-dropdown
id="weazel-news-status-dropdown"
class="weazel-composer-dropdown sky-ui-provider"
:class="{ 'sky-ui-provider--dark': phone.isDarkMode }"
:items="statusDropdownItems"
:label="t('composer.status')"
:opened="statusDropdownOpened"
placement="auto"
:target="statusDropdownTarget"
@backdropclick="closeComposerDropdowns"
@escape="closeComposerDropdowns"
@positionerror="closeComposerDropdowns"
@select="selectStatusDropdownItem"
/>
<sky-dialog
:opened="deleteDialogOpened"
@backdropclick="deleteDialogOpened = false"
@@ -1622,18 +1858,38 @@ onBeforeUnmount(() => {
position: absolute;
z-index: 2;
top: 50%;
right: 14px;
width: 30px;
height: 30px;
right: 7px;
width: var(--sky-touch-target);
min-width: var(--sky-touch-target);
height: var(--sky-touch-target);
min-height: var(--sky-touch-target);
display: grid;
place-items: center;
transform: translateY(-50%);
border: 0;
border-radius: 999px;
background: var(--weazel-line);
padding: 0;
background: transparent;
color: var(--weazel-muted);
}
.weazel-search-clear::before {
position: absolute;
width: 30px;
height: 30px;
border-radius: var(--sky-radius-pill);
background: var(--weazel-line);
content: '';
}
.weazel-search-clear:focus-visible {
outline: 2px solid var(--sky-app-accent, #007aff);
outline-offset: -2px;
}
.weazel-search-clear > svg {
position: relative;
}
.weazel-access-content {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
@@ -1831,15 +2087,14 @@ onBeforeUnmount(() => {
position: relative;
width: 100%;
height: 240px;
display: flex;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
scrollbar-width: none;
overflow: hidden;
touch-action: pan-y;
}
.weazel-detail-gallery::-webkit-scrollbar {
display: none;
.weazel-detail-gallery:focus-visible,
.weazel-detail-pagination button:focus-visible {
outline: 2px solid var(--sky-app-accent, #007aff);
outline-offset: -2px;
}
.weazel-detail-cover {
@@ -1847,9 +2102,62 @@ onBeforeUnmount(() => {
max-width: none;
height: 240px;
display: block;
flex: 0 0 100%;
object-fit: cover;
scroll-snap-align: start;
}
.weazel-detail-gallery-control {
position: absolute;
z-index: 2;
top: 50%;
width: var(--sky-touch-target) !important;
height: var(--sky-touch-target) !important;
min-width: var(--sky-touch-target) !important;
min-height: var(--sky-touch-target) !important;
transform: translateY(-50%);
background: rgb(0 0 0 / 58%) !important;
color: #fff !important;
}
.weazel-detail-gallery-control.is-previous {
left: var(--sky-space-2);
}
.weazel-detail-gallery-control.is-next {
right: var(--sky-space-2);
}
.weazel-detail-pagination {
position: absolute;
z-index: 2;
right: 50%;
bottom: 0;
display: flex;
transform: translateX(50%);
}
.weazel-detail-pagination button {
width: var(--sky-touch-target);
min-width: var(--sky-touch-target);
height: var(--sky-touch-target);
min-height: var(--sky-touch-target);
display: grid;
place-items: center;
border: 0;
padding: 0;
background: transparent;
}
.weazel-detail-pagination span {
width: 6px;
height: 6px;
border-radius: var(--sky-radius-pill);
background: rgb(255 255 255 / 55%);
box-shadow: 0 1px 3px rgb(0 0 0 / 45%);
}
.weazel-detail-pagination button.is-active span {
width: 16px;
background: #fff;
}
.weazel-detail-masthead {
@@ -1859,8 +2167,9 @@ onBeforeUnmount(() => {
.weazel-detail-count {
position: absolute;
z-index: 2;
right: var(--sky-page-gutter);
bottom: var(--sky-space-3);
top: var(--sky-space-3);
padding: 4px 9px;
display: inline-flex;
align-items: center;
@@ -2099,6 +2408,13 @@ onBeforeUnmount(() => {
resize: vertical;
}
.weazel-composer-select-value {
display: inline-flex;
align-items: center;
gap: var(--sky-space-1);
color: var(--weazel-muted);
}
.weazel-photo-source-actions {
padding: 0 var(--sky-space-3) var(--sky-space-3);
display: grid;
+3
View File
@@ -344,6 +344,9 @@ Locales["en"] = {
search = "Search Weazel News",
clearSearch = "Clear article search",
articleImages = "Article photos",
previousPhoto = "Show the previous article photo",
nextPhoto = "Show the next article photo",
showPhoto = "Show article photo {current} of {total}",
coverPreview = "Article cover preview",
removeCover = "Remove the selected cover image",
removePhoto = "Remove this article photo",