+
);
diff --git a/frontend/src/Components/MonitorToggleButton.tsx b/frontend/src/Components/MonitorToggleButton.tsx
index 1c1fcbbeb..36d95903f 100644
--- a/frontend/src/Components/MonitorToggleButton.tsx
+++ b/frontend/src/Components/MonitorToggleButton.tsx
@@ -54,6 +54,7 @@ function MonitorToggleButton(props: MonitorToggleButtonProps) {
name={iconName}
size={size}
title={title}
+ aria-label={title}
isDisabled={isDisabled}
isSpinning={isSaving}
{...otherProps}
diff --git a/frontend/src/Components/Page/Header/PageHeader.tsx b/frontend/src/Components/Page/Header/PageHeader.tsx
index c63447bbf..54a96697c 100644
--- a/frontend/src/Components/Page/Header/PageHeader.tsx
+++ b/frontend/src/Components/Page/Header/PageHeader.tsx
@@ -55,6 +55,7 @@ function PageHeader() {
diff --git a/frontend/src/Components/Page/PageContentBody.tsx b/frontend/src/Components/Page/PageContentBody.tsx
index 9c3ffcd0a..89d756645 100644
--- a/frontend/src/Components/Page/PageContentBody.tsx
+++ b/frontend/src/Components/Page/PageContentBody.tsx
@@ -1,5 +1,6 @@
import React, { ForwardedRef, forwardRef, ReactNode, useCallback } from 'react';
import Scroller, { OnScroll } from 'Components/Scroller/Scroller';
+import useScrollPosition from 'Helpers/Hooks/useScrollPosition';
import { isLocked } from 'Utilities/scrollLock';
import styles from './PageContentBody.css';
@@ -7,7 +8,7 @@ interface PageContentBodyProps {
className?: string;
innerClassName?: string;
children: ReactNode;
- initialScrollTop?: number;
+ scrollPositionKey?: string;
onScroll?: (payload: OnScroll) => void;
}
@@ -17,26 +18,32 @@ const PageContentBody = forwardRef(
className = styles.contentBody,
innerClassName = styles.innerContentBody,
children,
+ scrollPositionKey,
onScroll,
- ...otherProps
} = props;
- const onScrollWrapper = useCallback(
+ const { initialScrollTop, onScroll: onScrollMemo } =
+ useScrollPosition(scrollPositionKey);
+
+ const handleScroll = useCallback(
(payload: OnScroll) => {
- if (onScroll && !isLocked()) {
- onScroll(payload);
+ if (isLocked()) {
+ return;
}
+
+ onScrollMemo(payload);
+ onScroll?.(payload);
},
- [onScroll]
+ [onScroll, onScrollMemo]
);
return (
{isSmallScreen ? (
@@ -521,7 +522,7 @@ function PageSidebar() {
-
+
);
}
diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarItem.tsx b/frontend/src/Components/Page/Sidebar/PageSidebarItem.tsx
index 37d9bafa0..c2b8cfff1 100644
--- a/frontend/src/Components/Page/Sidebar/PageSidebarItem.tsx
+++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.tsx
@@ -46,11 +46,12 @@ function PageSidebarItem({
isActive && styles.isActiveLink
)}
to={to}
+ aria-current={isActive ? 'page' : undefined}
onPress={handlePress}
>
{!!iconName && (
-
+
)}
diff --git a/frontend/src/Components/SignalRListener.tsx b/frontend/src/Components/SignalRListener.tsx
index ce6ca51bf..de6d8e442 100644
--- a/frontend/src/Components/SignalRListener.tsx
+++ b/frontend/src/Components/SignalRListener.tsx
@@ -14,6 +14,8 @@ import Episode from 'Episode/Episode';
import { EpisodeFile } from 'EpisodeFile/EpisodeFile';
import { PagedQueryResponse } from 'Helpers/Hooks/usePagedApiQuery';
import Series from 'Series/Series';
+import { IndexerModel } from 'Settings/Indexers/useIndexers';
+import { NotificationModel } from 'Settings/Notifications/useConnections';
import { removeItem, updateItem } from 'Store/Actions/baseActions';
import { repopulatePage } from 'Utilities/pagePopulator';
import SignalRLogger from 'Utilities/SignalRLogger';
@@ -141,30 +143,11 @@ function SignalRListener() {
if (body.action === 'updated') {
const updatedItem = body.resource as Episode;
- queryClient.setQueriesData(
- { queryKey: ['/episode'] },
- (oldData: Episode[] | undefined) => {
- if (!oldData) {
- return oldData;
- }
-
- const itemIndex = oldData.findIndex(
- (item) => item.id === updatedItem.id
- );
-
- // Don't add episode if not found
- if (itemIndex === -1) {
- return oldData;
- }
-
- return oldData.map((item) => {
- if (item.id === updatedItem.id) {
- return updatedItem;
- }
-
- return item;
- });
- }
+ updateQueryClientItem(
+ queryClient,
+ ['/episode'],
+ updatedItem,
+ false // Don't add the episode to the list if it doesn't exist. Episodes should already be in the list since they are included in the series details.
);
}
@@ -179,30 +162,11 @@ function SignalRListener() {
if (body.action === 'updated') {
const updatedItem = body.resource as EpisodeFile;
- queryClient.setQueriesData(
- { queryKey: ['/episodeFile'] },
- (oldData: EpisodeFile[] | undefined) => {
- if (!oldData) {
- return oldData;
- }
-
- const itemIndex = oldData.findIndex(
- (item) => item.id === updatedItem.id
- );
-
- // Add episode file to the end
- if (itemIndex === -1) {
- return [...oldData, updatedItem];
- }
-
- return oldData.map((item) => {
- if (item.id === updatedItem.id) {
- return updatedItem;
- }
-
- return item;
- });
- }
+ updateQueryClientItem(
+ queryClient,
+ ['/episodeFile'],
+ updatedItem,
+ true // Add the episode file to the list if it doesn't exist. This can happen when an episode file is imported and wasn't previously in the list of episode files.
);
// Repopulate the page to handle recently imported file
@@ -210,24 +174,7 @@ function SignalRListener() {
} else if (body.action === 'deleted') {
const id = body.resource.id;
- queryClient.setQueriesData(
- { queryKey: ['/episodeFile'] },
- (oldData: EpisodeFile[] | undefined) => {
- if (!oldData) {
- return oldData;
- }
-
- const itemIndex = oldData.findIndex((item) => item.id === id);
-
- // Add episode file to the end
- if (itemIndex === -1) {
- return oldData;
- }
-
- return oldData.filter((item) => item.id !== id);
- }
- );
-
+ removeQueryClientItem(queryClient, ['/episodeFile'], id);
repopulatePage('episodeFileDeleted');
}
@@ -256,34 +203,39 @@ function SignalRListener() {
}
if (name === 'indexer') {
- const section = 'settings.indexers';
+ const updatedItem = body.resource as IndexerModel;
if (body.action === 'created' || body.action === 'updated') {
- dispatch(updateItem({ section, ...body.resource }));
+ updateQueryClientItem(queryClient, ['/indexer'], updatedItem, true);
} else if (body.action === 'deleted') {
- dispatch(removeItem({ section, id: body.resource.id }));
+ removeQueryClientItem(queryClient, ['/indexer'], body.resource.id);
}
return;
}
if (name === 'metadata') {
- const section = 'settings.metadata';
+ const updatedItem = body.resource as ModelBase;
if (body.action === 'updated') {
- dispatch(updateItem({ section, ...body.resource }));
+ updateQueryClientItem(queryClient, ['/metadata'], updatedItem, false);
}
return;
}
- if (name === 'notification') {
- const section = 'settings.notifications';
+ if (name === 'connection') {
+ const updatedItem = body.resource as NotificationModel;
if (body.action === 'created' || body.action === 'updated') {
- dispatch(updateItem({ section, ...body.resource }));
+ updateQueryClientItem(
+ queryClient,
+ ['/connection'],
+ updatedItem,
+ body.action === 'created' // Only add the connection to the list if it was created. If it was updated and it doesn't exist in the list, it likely means the connection is disabled and shouldn't be shown in the list.
+ );
} else if (body.action === 'deleted') {
- dispatch(removeItem({ section, id: body.resource.id }));
+ removeQueryClientItem(queryClient, ['/connection'], body.resource.id);
}
return;
@@ -350,42 +302,16 @@ function SignalRListener() {
if (body.action === 'updated') {
const updatedItem = body.resource as Series;
- queryClient.setQueryData
(
+ updateQueryClientItem(
+ queryClient,
['/series'],
- (oldData: Series[] | undefined) => {
- if (!oldData) {
- return oldData;
- }
-
- return oldData.map((item) => {
- if (item.id === updatedItem.id) {
- return {
- ...item,
- ...updatedItem,
- };
- }
-
- return item;
- });
- }
+ updatedItem,
+ false // Don't add the series to the list if it doesn't exist. Series should already be in the list since they are included in the calendar and series details.
);
repopulatePage('seriesUpdated');
} else if (body.action === 'deleted') {
- dispatch(removeItem({ section: 'series', id: body.resource.id }));
-
- queryClient.setQueriesData(
- { queryKey: ['/series'] },
- (oldData: Series[] | undefined) => {
- if (!oldData) {
- return oldData;
- }
-
- return oldData.filter((item) => {
- return item.id !== body.resource.id;
- });
- }
- );
+ removeQueryClientItem(queryClient, ['/series'], body.resource.id);
}
return;
@@ -521,3 +447,50 @@ const updatePagedItem = (
}
);
};
+
+const updateQueryClientItem = (
+ queryClient: ReturnType,
+ queryKey: QueryKey,
+ updatedItem: T,
+ addMissing: boolean
+) => {
+ queryClient.setQueriesData({ queryKey }, (oldData: T[] | undefined) => {
+ if (!oldData) {
+ return oldData;
+ }
+
+ const itemIndex = oldData.findIndex((item) => item.id === updatedItem.id);
+
+ if (itemIndex === -1 && addMissing) {
+ return [...oldData, updatedItem];
+ }
+
+ return oldData.map((item) => {
+ if (item.id === updatedItem.id) {
+ return updatedItem;
+ }
+
+ return item;
+ });
+ });
+};
+
+const removeQueryClientItem = (
+ queryClient: ReturnType,
+ queryKey: QueryKey,
+ id: T['id']
+) => {
+ queryClient.setQueriesData({ queryKey }, (oldData: T[] | undefined) => {
+ if (!oldData) {
+ return oldData;
+ }
+
+ const itemIndex = oldData.findIndex((item) => item.id === id);
+
+ if (itemIndex === -1) {
+ return oldData;
+ }
+
+ return oldData.filter((item) => item.id !== id);
+ });
+};
diff --git a/frontend/src/Components/Table/Table.tsx b/frontend/src/Components/Table/Table.tsx
index c72b68b96..995529ec2 100644
--- a/frontend/src/Components/Table/Table.tsx
+++ b/frontend/src/Components/Table/Table.tsx
@@ -7,6 +7,7 @@ import { icons, scrollDirections } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections';
import { CheckInputChanged } from 'typings/inputs';
import { TableOptionsChangePayload } from 'typings/Table';
+import translate from 'Utilities/String/translate';
import Column from './Column';
import TableHeader from './TableHeader';
import TableHeaderCell from './TableHeaderCell';
@@ -94,7 +95,10 @@ function Table({
canModifyColumns={canModifyColumns}
onTableOptionChange={onTableOptionChange}
>
-
+
);
diff --git a/frontend/src/Components/Table/TableHeaderCell.tsx b/frontend/src/Components/Table/TableHeaderCell.tsx
index 13b8cf0f7..9311a81a5 100644
--- a/frontend/src/Components/Table/TableHeaderCell.tsx
+++ b/frontend/src/Components/Table/TableHeaderCell.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useMemo } from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons, sortDirections } from 'Helpers/Props';
@@ -41,6 +41,20 @@ function TableHeaderCell({
? icons.SORT_ASCENDING
: icons.SORT_DESCENDING;
+ const ariaSortValue = useMemo(() => {
+ if (!isSortable) {
+ return undefined;
+ }
+
+ if (!isSorting) {
+ return 'none';
+ }
+
+ return sortDirection === sortDirections.ASCENDING
+ ? 'ascending'
+ : 'descending';
+ }, [isSorting, sortDirection, isSortable]);
+
const handlePress = useCallback(() => {
if (fixedSortDirection) {
onSortPress?.(name, fixedSortDirection);
@@ -56,14 +70,20 @@ function TableHeaderCell({
className={className}
// label={typeof label === 'function' ? label() : label}
title={typeof columnLabel === 'function' ? columnLabel() : columnLabel}
+ scope="col"
+ aria-sort={ariaSortValue}
onPress={handlePress}
>
{children}
- {isSorting && }
+ {isSorting ? (
+
+ ) : null}
) : (
- {children} |
+
+ {children}
+ |
);
}
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsModal.tsx b/frontend/src/Components/Table/TableOptions/TableOptionsModal.tsx
index 4c0d69339..0fd9b2ae4 100644
--- a/frontend/src/Components/Table/TableOptions/TableOptionsModal.tsx
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsModal.tsx
@@ -110,7 +110,7 @@ function TableOptionsModal({
const handleColumnDragEnd = useCallback(
(didDrop: boolean) => {
- if (didDrop && dragIndex && dropIndex !== null) {
+ if (didDrop && dragIndex !== null && dropIndex !== null) {
const newColumns = [...columns];
const items = newColumns.splice(dragIndex, 1);
newColumns.splice(dropIndex, 0, items[0]);
diff --git a/frontend/src/Components/Table/TablePager.tsx b/frontend/src/Components/Table/TablePager.tsx
index 411b111bf..5394ccc69 100644
--- a/frontend/src/Components/Table/TablePager.tsx
+++ b/frontend/src/Components/Table/TablePager.tsx
@@ -108,9 +108,10 @@ function TablePager({
isFirstPage && styles.disabledPageButton
)}
isDisabled={isFirstPage}
+ aria-label={translate('PagerGoToFirstPage')}
onPress={handleFirstPagePress}
>
-
+
-
+
{isShowingPageSelect ? null : (
{page} / {totalPages}
@@ -153,9 +159,10 @@ function TablePager({
isLastPage && styles.disabledPageButton
)}
isDisabled={isLastPage}
+ aria-label={translate('PagerGoToNextPage')}
onPress={onNextPagePress}
>
-
+
-
+
diff --git a/frontend/src/Components/withScrollPosition.tsx b/frontend/src/Components/withScrollPosition.tsx
deleted file mode 100644
index f688a6253..000000000
--- a/frontend/src/Components/withScrollPosition.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import React from 'react';
-import { RouteComponentProps } from 'react-router-dom';
-import scrollPositions from 'Store/scrollPositions';
-
-interface WrappedComponentProps {
- initialScrollTop: number;
-}
-
-interface ScrollPositionProps {
- history: RouteComponentProps['history'];
- location: RouteComponentProps['location'];
- match: RouteComponentProps['match'];
-}
-
-function withScrollPosition(
- WrappedComponent: React.FC