feat(front): make pagination responsive and accessible

https://benmyers.dev/blog/native-visually-hidden/
2506-fix-frontend-regressions
Flupsi 2025-08-06 17:37:07 +02:00
rodzic 30583f4a53
commit 26fd4b575d
9 zmienionych plików z 138 dodań i 113 usunięć

Wyświetl plik

@ -3,6 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
import { type ColorProps, type VariantProps, type DefaultProps, type RaisedProps, type PastelProps, color } from '~/composables/color.ts'
import { type WidthProps, width } from '~/composables/width'
import Button from '~/components/ui/Button.vue'
import Layout from '~/components/ui/Layout.vue'
@ -18,7 +19,9 @@ const { icon, placeholder, ...props } = defineProps<{
reset?:() => void;
} & (ColorProps | DefaultProps | PastelProps)
& VariantProps
& RaisedProps>()
& RaisedProps
& WidthProps
>()
// TODO(A11y): Add `inputmode="numeric" pattern="[0-9]*"` to input if model type is number:
// https://technology.blog.gov.uk/2020/02/24/why-the-gov-uk-design-system-team-changed-the-input-type-for-numbers/
@ -79,7 +82,7 @@ const model = defineModel<string|number>({ required: true })
</span>
<input
v-bind="{...$attrs, ...attributes, ...color(props, ['solid', 'default', 'secondary'])()}"
v-bind="{...$attrs, ...attributes, ...color(props, ['solid', 'default', 'secondary'])(width(props)())}"
ref="input"
v-model="model"
:autofocus="autofocus || undefined"

Wyświetl plik

@ -8,7 +8,7 @@ const props = defineProps<{
noRule?: true,
noWrap?: true,
} & { [P in 'stack' | 'grid' | 'flex' | 'columns' | 'row' | 'page']?: true | string }
& { [C in 'nav' | 'aside' | 'header' | 'footer' | 'main' | 'label' | 'form' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5']?: true }
& { [C in 'nav' | 'ul' | 'aside' | 'header' | 'footer' | 'main' | 'label' | 'form' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5']?: true }
& { [G in 'no-gap' | `gap-${'4' | '8' | '12' | '16' | '24' | '32' | '48' | '64' | 'auto'}` ]?: true }
&(PastelProps | ColorProps | DefaultProps)
& RaisedProps
@ -38,7 +38,7 @@ const attributes = computed(() => ({
<template>
<component
:is="props.nav ? 'nav' : props.aside ? 'aside' : props.header ? 'header' : props.footer ? 'footer' : props.main ? 'main' : props.label ? 'label' : props.form ? 'form' : props.h1 ? 'h1' : props.h2 ? 'h2' : props.h3 ? 'h3' : props.h4 ? 'h4' : props.h5 ? 'h5' : 'div'"
:is="props.nav ? 'nav' : props.ul ? 'ul' : props.aside ? 'aside' : props.header ? 'header' : props.footer ? 'footer' : props.main ? 'main' : props.label ? 'label' : props.form ? 'form' : props.h1 ? 'h1' : props.h2 ? 'h2' : props.h3 ? 'h3' : props.h4 ? 'h4' : props.h5 ? 'h5' : 'div'"
:class="[
$style.layout,
('noGap' in props && props.noGap === true) || $style.gap,

Wyświetl plik

@ -6,6 +6,7 @@ import { isMobileView } from '~/composables/screen'
import Button from '~/components/ui/Button.vue'
import Input from '~/components/ui/Input.vue'
import Spacer from '~/components/ui/Spacer.vue'
const { t } = useI18n()
@ -23,7 +24,11 @@ const goTo = ref<number | string>('' as const)
const range = (start: number, end: number) => Array.from({ length: end - start + 1 }, (_, i) => i + start)
/* Why? What? */
/* Render only pages nearby if >5 pages:
- If near the end, show 1, ..., end-4, end-3, end-2, end-1, end
- If smaller than -4 and larger than 4, show 1, ..., current-1, current, current+1, ..., end
- If near beginning, show 1, 2, 3, 4, 5, ..., 12
*/
const renderPages = computed(() => {
const start = range(2, 5)
const end = range(pages - 4, pages - 1)
@ -72,6 +77,11 @@ watch(page, (_) => {
})
</script>
<!-- REDESIGN, without custom CSS -->
<template>
<nav
ref="pagination"
@ -85,78 +95,66 @@ watch(page, (_) => {
<Button
low-height
min-content
:square-small="isSmall"
:disabled="page <= 1"
:aria-label="t('vui.aria.pagination.gotoPrevious')"
secondary
ghost
icon="bi-chevron-left"
class="visually-hidden-when-small"
@click="page -= 1"
>
<span v-if="!isSmall">{{ t('vui.pagination.previous') }}</span>
</Button>
</li>
<Spacer
no-size
grow
/>
<template
v-for="(i, index) in (isSmall ? [] : renderPages)"
v-for="(i, index) in (renderPages)"
:key="i"
>
<li>
<Button
v-if="i <= pages && i > 0 && pages > 3"
v-if="i <= pages && i > 0 && pages > 2"
square-small
:aria-label="page !== i ? t('vui.aria.pagination.gotoPage', i) : t('vui.aria.pagination.currentPage', page)"
:secondary="page !== i"
:aria-pressed="page === i"
circular
ghost
@click="page = i"
>
{{ i }}
</Button>
</li>
<li v-if="i + 1 < renderPages[index + 1]">
{{ (() => '…')() }}
</li>
</template>
<template v-if="isSmall">
<li>
<Button
square-small
:aria-label="page !== 1 ? t('vui.aria.pagination.gotoPage', page) : t('vui.aria.pagination.currentPage', page)"
:secondary="page !== 1"
@click="page = 1"
>
{{ (() => '1')() }}
</Button>
</li>
<li v-if="page === 1 || page === pages">
{{ (() => '…')() }}
</li>
<li v-else>
<Button
square-small
:aria-label="t('vui.aria.pagination.currentPage', page)"
aria-current="true"
>
{{ page }}
</Button>
</li>
<li>
<Button
square-small
:aria-label="page !== pages ? t('vui.aria.pagination.gotoPage', page) : t('vui.aria.pagination.currentPage', page)"
:secondary="page !== pages"
@click="page = pages"
>
{{ pages }}
</Button>
<li
v-if="i + 1 < renderPages[index + 1]"
style="user-select: none;"
>
{{ t('vui.pagination.ellipsis') }}
</li>
</template>
<Spacer
no-size
grow
/>
<li>
<Button
low-height
min-content
:square-small="isSmall"
:disabled="page >= pages"
:aria-label="t('vui.aria.pagination.gotoNext')"
secondary
ghost
icon="right bi-chevron-right"
class="visually-hidden-when-small"
@click="page += 1"
>
<span v-if="!isSmall">{{ t('vui.pagination.next') }}</span>
@ -164,17 +162,23 @@ watch(page, (_) => {
</li>
</ul>
<!-- \d{1,100} -->
<div class="goto">
{{ t('vui.go-to') }}
<Spacer size-8 />
<label
style="transform: translateY(-7px);"
>
<Input
v-model.number="goTo"
:placeholder="page?.toString()"
:placeholder="t('vui.pagination.enterPageNumber')"
low-height
tiny
inputmode="numeric"
pattern="[0-9]*"
:aria-label="t('vui.aria.pagination.goToPage', { goTo })"
@click.stop
@keyup.enter="setPage"
@blur="setPage"
/>
</div>
</label>
</nav>
</template>

Wyświetl plik

@ -1,20 +1,27 @@
.funkwhale {
&.pagination {
@include light-theme {
> .goto {
border-left: 1px solid var(--fw-gray-200);
}
}
/* People using a screen reader want to be able to use default 'previous page', 'next page' controls*/
@include dark-theme {
> .goto {
border-left: 1px solid var(--fw-gray-800);
}
@media screen and (max-width: 671px) {
flex-wrap: wrap;
.visually-hidden-when-small:not(:focus):not(:active) {
border: 0;
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
}
}
height: 34px;
display: flex;
justify-content: center;
&.is-small {
> .pages {
@ -33,46 +40,23 @@
list-style: none;
margin: 0;
padding: 0;
gap: 4px;
margin-bottom: 8px;
> li {
margin-top: 0;
margin: 0 0.5ch;
margin: 0;
text-align: center;
&:not(:first-child):not(:last-child) {
width: 34px;
}
> .funkwhale.button {
min-width: 34px;
margin: 0;
}
&:first-child > .funkwhale.button,
&:last-child > .funkwhale.button {
min-width: 94px;
width: 94px;
text-align: center;
}
}
}
> .goto {
margin-left: 16px;
padding-left: 16px;
display: flex;
align-items: center;
white-space: nowrap;
> .funkwhale.input {
margin-left: 16px;
width: calc(3ch + 32px);
input {
text-align: center;
padding: 6px 8px;
}
}
}
}
}

Wyświetl plik

@ -17,6 +17,7 @@ export type WidthProps =
| { squareSmall?: true }
| { lowHeight?: true }
| { normalHeight?: true }
| { circular? : true }
export type Key = KeysOfUnion<WidthProps>
const widths = {
@ -30,6 +31,7 @@ const widths = {
auto: 'width: auto;',
full: 'width: auto; --grid-column: 1 / -1; place-self: stretch;',
grow: 'flex-grow: 1;',
circular: 'border-radius: 100%;',
width: (w: string) => `width: ${w}; flex-grow:0;`
} as const

Wyświetl plik

@ -4689,25 +4689,55 @@
}
},
"vui": {
"albums": "{n} album | {n} albums",
"aria": {
"pagination": {
"currentPage": "Current Page, Page {n}",
"gotoNext": "Goto Next Page",
"gotoPage": "Goto Page {n}",
"gotoPrevious": "Goto Previous Page",
"nav": "Pagination Navigation"
}
},
"by-user": "by {username}",
"episodes": "{n} episode | {n} episodes",
"go-to": "Go to",
"pagination": {
"label": "Pagination",
"next": "Next page",
"previous": "Previous page"
},
"addItem": "Add {item}",
"cancel": "Cancel",
"delete": "Delete",
"deselect": "Deselect",
"deletItem": "Delete {item}",
"pressKey": "Press {key}",
"pressKeyToAction": "Press {key} to {action}",
"preview": "Preview",
"resetTo": "Reset to {previousValue}",
"radio": "Radio",
"tracks": "{n} track | {n} tracks"
"albums": "{n} album | {n} albums",
"tracks": "{n} track | {n} tracks",
"episodes": "{n} episode | {n} episodes",
"by-user": "by {username}",
"pagination": {
"previous": "Previous",
"next": "Next",
"ellipsis": "…",
"enterPageNumber": "Go to page…"
},
"aria": {
"close": "Close",
"text": {
"paragraph": "paragraph",
"heading1": "heading1",
"heading2": "heading2",
"heading3": "heading3",
"heading4": "heading4",
"heading5": "heading5",
"heading6": "heading6",
"quote": "quote",
"orderedList": "ordered list",
"unorderedList": "unordered list",
"bold": "bold",
"italic": "italic",
"strikethrough": "strikethrough",
"link": "link"
},
"password": {
"show": "Show password",
"hide": "Hide password"
},
"pagination": {
"nav": "Pagination Navigation",
"goToPage": "Go to Page {n}",
"gotoPrevious": "Go to Previous Page",
"gotoNext": "Go to Next Page",
"currentPage": "Current Page, Page {n}"
}
}
}
}

Wyświetl plik

@ -17,10 +17,11 @@
"tracks": "{n} track | {n} tracks",
"episodes": "{n} episode | {n} episodes",
"by-user": "by {username}",
"go-to": "Go to",
"pagination": {
"previous": "Previous",
"next": "Next"
"next": "Next",
"ellipsis": "…",
"enterPageNumber": "Go to page…"
},
"aria": {
"close": "Close",
@ -46,7 +47,7 @@
},
"pagination": {
"nav": "Pagination Navigation",
"gotoPage": "Go to Page {n}",
"goToPage": "Go to Page {n}",
"gotoPrevious": "Go to Previous Page",
"gotoNext": "Go to Next Page",
"currentPage": "Current Page, Page {n}"

Wyświetl plik

@ -632,9 +632,9 @@
background-color: var(--active-background-color);
}
&[disabled] {
color: var(--disabled-color);
border-color: var(--disabled-border-color);
background-color:var(--disabled-background-color);
color: color-mix(in oklab, var(--disabled-color) 50%, var(--color-over-transparent, var(--color)));
border-color: transparent;
background-color: transparent;
}
// Link
/* &.active {

Wyświetl plik

@ -1,4 +1,3 @@
.ui.bottom-player {
z-index: 999999;
width: 100%;
@ -14,9 +13,9 @@
height: 0.2rem;
}
}
}
.ui.bottom-player > .segment.fixed-controls {
max-width: 100vw;
color: var(--color);
background: var(--player-background);
width: 100%;
@ -147,14 +146,16 @@
accent-color: var(--vibrant-color);
}
.looping, .shuffling,
.looping:hover, .shuffling:hover {
.looping,
.shuffling,
.looping:hover,
.shuffling:hover {
> i {
color: var(--vibrant-color);
}
}
@include media(">desktop") {
@include media('>desktop') {
&:not(.fluid) {
width: 20%;
}