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 { useI18n } from 'vue-i18n'
import onKeyboardShortcut from '~/composables/onKeyboardShortcut' import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
import { type ColorProps, type VariantProps, type DefaultProps, type RaisedProps, type PastelProps, color } from '~/composables/color.ts' 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 Button from '~/components/ui/Button.vue'
import Layout from '~/components/ui/Layout.vue' import Layout from '~/components/ui/Layout.vue'
@ -18,7 +19,9 @@ const { icon, placeholder, ...props } = defineProps<{
reset?:() => void; reset?:() => void;
} & (ColorProps | DefaultProps | PastelProps) } & (ColorProps | DefaultProps | PastelProps)
& VariantProps & VariantProps
& RaisedProps>() & RaisedProps
& WidthProps
>()
// TODO(A11y): Add `inputmode="numeric" pattern="[0-9]*"` to input if model type is number: // 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/ // 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> </span>
<input <input
v-bind="{...$attrs, ...attributes, ...color(props, ['solid', 'default', 'secondary'])()}" v-bind="{...$attrs, ...attributes, ...color(props, ['solid', 'default', 'secondary'])(width(props)())}"
ref="input" ref="input"
v-model="model" v-model="model"
:autofocus="autofocus || undefined" :autofocus="autofocus || undefined"

Wyświetl plik

@ -8,7 +8,7 @@ const props = defineProps<{
noRule?: true, noRule?: true,
noWrap?: true, noWrap?: true,
} & { [P in 'stack' | 'grid' | 'flex' | 'columns' | 'row' | 'page']?: true | string } } & { [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 } & { [G in 'no-gap' | `gap-${'4' | '8' | '12' | '16' | '24' | '32' | '48' | '64' | 'auto'}` ]?: true }
&(PastelProps | ColorProps | DefaultProps) &(PastelProps | ColorProps | DefaultProps)
& RaisedProps & RaisedProps
@ -38,7 +38,7 @@ const attributes = computed(() => ({
<template> <template>
<component <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="[ :class="[
$style.layout, $style.layout,
('noGap' in props && props.noGap === true) || $style.gap, ('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 Button from '~/components/ui/Button.vue'
import Input from '~/components/ui/Input.vue' import Input from '~/components/ui/Input.vue'
import Spacer from '~/components/ui/Spacer.vue'
const { t } = useI18n() 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) 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 renderPages = computed(() => {
const start = range(2, 5) const start = range(2, 5)
const end = range(pages - 4, pages - 1) const end = range(pages - 4, pages - 1)
@ -72,6 +77,11 @@ watch(page, (_) => {
}) })
</script> </script>
<!-- REDESIGN, without custom CSS -->
<template> <template>
<nav <nav
ref="pagination" ref="pagination"
@ -85,78 +95,66 @@ watch(page, (_) => {
<Button <Button
low-height low-height
min-content min-content
:square-small="isSmall"
:disabled="page <= 1" :disabled="page <= 1"
:aria-label="t('vui.aria.pagination.gotoPrevious')" :aria-label="t('vui.aria.pagination.gotoPrevious')"
secondary secondary
ghost
icon="bi-chevron-left" icon="bi-chevron-left"
class="visually-hidden-when-small"
@click="page -= 1" @click="page -= 1"
> >
<span v-if="!isSmall">{{ t('vui.pagination.previous') }}</span> <span v-if="!isSmall">{{ t('vui.pagination.previous') }}</span>
</Button> </Button>
</li> </li>
<Spacer
no-size
grow
/>
<template <template
v-for="(i, index) in (isSmall ? [] : renderPages)" v-for="(i, index) in (renderPages)"
:key="i" :key="i"
> >
<li> <li>
<Button <Button
v-if="i <= pages && i > 0 && pages > 3" v-if="i <= pages && i > 0 && pages > 2"
square-small square-small
:aria-label="page !== i ? t('vui.aria.pagination.gotoPage', i) : t('vui.aria.pagination.currentPage', page)" :aria-label="page !== i ? t('vui.aria.pagination.gotoPage', i) : t('vui.aria.pagination.currentPage', page)"
:secondary="page !== i" :secondary="page !== i"
:aria-pressed="page === i"
circular
ghost
@click="page = i" @click="page = i"
> >
{{ i }} {{ i }}
</Button> </Button>
</li> </li>
<li v-if="i + 1 < renderPages[index + 1]"> <li
{{ (() => '…')() }} v-if="i + 1 < renderPages[index + 1]"
</li> style="user-select: none;"
</template> >
<template v-if="isSmall"> {{ t('vui.pagination.ellipsis') }}
<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> </li>
</template> </template>
<Spacer
no-size
grow
/>
<li> <li>
<Button <Button
low-height low-height
min-content min-content
:square-small="isSmall"
:disabled="page >= pages" :disabled="page >= pages"
:aria-label="t('vui.aria.pagination.gotoNext')" :aria-label="t('vui.aria.pagination.gotoNext')"
secondary secondary
ghost
icon="right bi-chevron-right" icon="right bi-chevron-right"
class="visually-hidden-when-small"
@click="page += 1" @click="page += 1"
> >
<span v-if="!isSmall">{{ t('vui.pagination.next') }}</span> <span v-if="!isSmall">{{ t('vui.pagination.next') }}</span>
@ -164,17 +162,23 @@ watch(page, (_) => {
</li> </li>
</ul> </ul>
<!-- \d{1,100} --> <!-- \d{1,100} -->
<div class="goto"> <Spacer size-8 />
{{ t('vui.go-to') }} <label
style="transform: translateY(-7px);"
>
<Input <Input
v-model.number="goTo" v-model.number="goTo"
:placeholder="page?.toString()" :placeholder="t('vui.pagination.enterPageNumber')"
low-height
tiny
inputmode="numeric" inputmode="numeric"
pattern="[0-9]*" pattern="[0-9]*"
:aria-label="t('vui.aria.pagination.goToPage', { goTo })"
@click.stop @click.stop
@keyup.enter="setPage" @keyup.enter="setPage"
@blur="setPage"
/> />
</div> </label>
</nav> </nav>
</template> </template>

Wyświetl plik

@ -1,20 +1,27 @@
.funkwhale { .funkwhale {
&.pagination { &.pagination {
@include light-theme { /* People using a screen reader want to be able to use default 'previous page', 'next page' controls*/
> .goto {
border-left: 1px solid var(--fw-gray-200);
}
}
@include dark-theme { @media screen and (max-width: 671px) {
> .goto { flex-wrap: wrap;
border-left: 1px solid var(--fw-gray-800); .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; height: 34px;
display: flex; display: flex;
justify-content: center;
&.is-small { &.is-small {
> .pages { > .pages {
@ -33,46 +40,23 @@
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
gap: 4px;
margin-bottom: 8px;
> li { > li {
margin-top: 0; margin: 0;
margin: 0 0.5ch;
text-align: center; text-align: center;
&:not(:first-child):not(:last-child) { &:not(:first-child):not(:last-child) {
width: 34px;
}
> .funkwhale.button {
min-width: 34px; min-width: 34px;
margin: 0;
} }
&:first-child > .funkwhale.button, &:first-child > .funkwhale.button,
&:last-child > .funkwhale.button { &:last-child > .funkwhale.button {
min-width: 94px; width: 94px;
text-align: center; 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 } | { squareSmall?: true }
| { lowHeight?: true } | { lowHeight?: true }
| { normalHeight?: true } | { normalHeight?: true }
| { circular? : true }
export type Key = KeysOfUnion<WidthProps> export type Key = KeysOfUnion<WidthProps>
const widths = { const widths = {
@ -30,6 +31,7 @@ const widths = {
auto: 'width: auto;', auto: 'width: auto;',
full: 'width: auto; --grid-column: 1 / -1; place-self: stretch;', full: 'width: auto; --grid-column: 1 / -1; place-self: stretch;',
grow: 'flex-grow: 1;', grow: 'flex-grow: 1;',
circular: 'border-radius: 100%;',
width: (w: string) => `width: ${w}; flex-grow:0;` width: (w: string) => `width: ${w}; flex-grow:0;`
} as const } as const

Wyświetl plik

@ -4689,25 +4689,55 @@
} }
}, },
"vui": { "vui": {
"albums": "{n} album | {n} albums", "addItem": "Add {item}",
"aria": { "cancel": "Cancel",
"pagination": { "delete": "Delete",
"currentPage": "Current Page, Page {n}", "deselect": "Deselect",
"gotoNext": "Goto Next Page", "deletItem": "Delete {item}",
"gotoPage": "Goto Page {n}", "pressKey": "Press {key}",
"gotoPrevious": "Goto Previous Page", "pressKeyToAction": "Press {key} to {action}",
"nav": "Pagination Navigation" "preview": "Preview",
} "resetTo": "Reset to {previousValue}",
},
"by-user": "by {username}",
"episodes": "{n} episode | {n} episodes",
"go-to": "Go to",
"pagination": {
"label": "Pagination",
"next": "Next page",
"previous": "Previous page"
},
"radio": "Radio", "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", "tracks": "{n} track | {n} tracks",
"episodes": "{n} episode | {n} episodes", "episodes": "{n} episode | {n} episodes",
"by-user": "by {username}", "by-user": "by {username}",
"go-to": "Go to",
"pagination": { "pagination": {
"previous": "Previous", "previous": "Previous",
"next": "Next" "next": "Next",
"ellipsis": "…",
"enterPageNumber": "Go to page…"
}, },
"aria": { "aria": {
"close": "Close", "close": "Close",
@ -46,7 +47,7 @@
}, },
"pagination": { "pagination": {
"nav": "Pagination Navigation", "nav": "Pagination Navigation",
"gotoPage": "Go to Page {n}", "goToPage": "Go to Page {n}",
"gotoPrevious": "Go to Previous Page", "gotoPrevious": "Go to Previous Page",
"gotoNext": "Go to Next Page", "gotoNext": "Go to Next Page",
"currentPage": "Current Page, Page {n}" "currentPage": "Current Page, Page {n}"

Wyświetl plik

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

Wyświetl plik

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