Add focus handling for the details page

This commit is contained in:
Zoe Roux 2023-01-16 16:15:37 +09:00
parent a8a8b45f4a
commit 25b4e95128
No known key found for this signature in database
GPG Key ID: B2AB52A2636E5C46
15 changed files with 197 additions and 122 deletions

View File

@ -67,7 +67,7 @@ const ThemedStack = ({ onLayout }: { onLayout?: () => void }) => {
backgroundColor: theme.background, backgroundColor: theme.background,
}, },
headerStyle: { headerStyle: {
backgroundColor: theme.appbar, backgroundColor: theme.accent,
}, },
headerTintColor: theme.colors.white, headerTintColor: theme.colors.white,
}} }}

View File

@ -46,7 +46,7 @@
"react-native-screens": "~3.18.0", "react-native-screens": "~3.18.0",
"react-native-svg": "13.4.0", "react-native-svg": "13.4.0",
"react-native-video": "alpha", "react-native-video": "alpha",
"yoshiki": "1.2.0" "yoshiki": "1.2.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.19.3", "@babel/core": "^7.19.3",

View File

@ -36,7 +36,7 @@
"react-native-web": "^0.18.10", "react-native-web": "^0.18.10",
"solito": "^2.0.5", "solito": "^2.0.5",
"superjson": "^1.11.0", "superjson": "^1.11.0",
"yoshiki": "1.2.0", "yoshiki": "1.2.1",
"zod": "^3.19.1" "zod": "^3.19.1"
}, },
"devDependencies": { "devDependencies": {

View File

@ -22,9 +22,10 @@ import React, { ComponentProps, ComponentType, ForwardedRef, forwardRef } from "
import { Platform, PressableProps, ViewStyle } from "react-native"; import { Platform, PressableProps, ViewStyle } from "react-native";
import { SvgProps } from "react-native-svg"; import { SvgProps } from "react-native-svg";
import { YoshikiStyle } from "yoshiki/dist/type"; import { YoshikiStyle } from "yoshiki/dist/type";
import { px, useYoshiki } from "yoshiki/native"; import { px, Theme, useYoshiki } from "yoshiki/native";
import { PressableFeedback } from "./links"; import { PressableFeedback } from "./links";
import { ts } from "./utils"; import { alpha } from "./themes";
import { Breakpoint, ts, useBreakpointValue } from "./utils";
declare module "react" { declare module "react" {
function forwardRef<T, P = {}>( function forwardRef<T, P = {}>(
@ -34,24 +35,25 @@ declare module "react" {
type IconProps = { type IconProps = {
icon: ComponentType<SvgProps>; icon: ComponentType<SvgProps>;
color?: YoshikiStyle<string>; color?: Breakpoint<string>;
size?: YoshikiStyle<number | string>; size?: YoshikiStyle<number | string>;
}; };
export const Icon = ({ icon: Icon, color, size = 24, ...props }: IconProps) => { export const Icon = ({ icon: Icon, color, size = 24, ...props }: IconProps) => {
const { css, theme } = useYoshiki(); const { css, theme } = useYoshiki();
const computed = css( // eslint-disable-next-line react-hooks/rules-of-hooks
{ width: size, height: size, fill: color ?? theme.contrast } as ViewStyle, const colorValue = Platform.OS !== "web" ? useBreakpointValue(color) : null;
props,
);
return ( return (
<Icon <Icon
{...Platform.select<SvgProps>({ {...Platform.select<SvgProps>({
web: computed, web: css({ width: size, height: size, fill: color ?? theme.contrast } as ViewStyle, props),
default: { default: {
height: computed.style?.height, height: size,
width: computed.style?.width, width: size,
...computed, // @ts-ignore
fill: colorValue ?? theme.contrast,
...props,
}, },
})} })}
/> />
@ -78,11 +80,17 @@ export const IconButton = forwardRef(function _IconButton<AsProps = PressablePro
<Container <Container
ref={ref as any} ref={ref as any}
accessibilityRole="button" accessibilityRole="button"
focusRipple
{...(css( {...(css(
{ {
p: ts(1), p: ts(1),
m: px(2), m: px(2),
borderRadius: 9999, borderRadius: 9999,
fover: {
self: {
bg: (theme: Theme) => alpha(theme.contrast, 0.5),
},
},
}, },
asProps, asProps,
) as AsProps)} ) as AsProps)}
@ -99,10 +107,16 @@ export const IconFab = <AsProps = PressableProps,>(
return ( return (
<IconButton <IconButton
colors={theme.colors.black} color={theme.colors.black}
{...(css( {...(css(
{ {
bg: (theme) => theme.accent, bg: (theme) => theme.accent,
fover: {
self: {
transform: [{ scale: 1.3 }],
bg: (theme: Theme) => theme.accent,
},
},
}, },
props, props,
) as any)} ) as any)}

View File

@ -50,25 +50,24 @@ export const A = ({
); );
}; };
export const PressableFeedback = forwardRef<View, PressableProps>(function _Feedback( export const PressableFeedback = forwardRef<View, PressableProps>(
{ children, ...props }, function _Feedback({ children, ...props }, ref) {
ref, const theme = useTheme();
) {
const theme = useTheme();
return ( return (
<Pressable <Pressable
ref={ref} ref={ref}
// TODO: Enable ripple on tv. Waiting for https://github.com/react-native-tvos/react-native-tvos/issues/440 // TODO: Enable ripple on tv. Waiting for https://github.com/react-native-tvos/react-native-tvos/issues/440
{...(Platform.isTV {...(Platform.isTV
? {} ? {}
: { android_ripple: { foreground: true, color: alpha(theme.contrast, 0.5) as any } })} : { android_ripple: { foreground: true, color: alpha(theme.contrast, 0.5) as any } })}
{...props} {...props}
> >
{children} {children}
</Pressable> </Pressable>
); );
}); },
);
export const Link = ({ export const Link = ({
href, href,

View File

@ -24,13 +24,12 @@ import { ThemeBuilder } from "./theme";
export const catppuccin: ThemeBuilder = { export const catppuccin: ThemeBuilder = {
light: { light: {
// Catppuccin latte // Catppuccin latte
appbar: "#e64553",
overlay0: "#9ca0b0", overlay0: "#9ca0b0",
overlay1: "#7c7f93", overlay1: "#7c7f93",
link: "#1e66f5", link: "#1e66f5",
default: { default: {
background: "#eff1f5", background: "#eff1f5",
accent: "#ea76cb", accent: "#e64553",
divider: "#8c8fa1", divider: "#8c8fa1",
heading: "#4c4f69", heading: "#4c4f69",
paragraph: "#5c5f77", paragraph: "#5c5f77",
@ -55,13 +54,12 @@ export const catppuccin: ThemeBuilder = {
}, },
dark: { dark: {
// Catppuccin mocha // Catppuccin mocha
appbar: "#89b4fa",
overlay0: "#6c7086", overlay0: "#6c7086",
overlay1: "#9399b2", overlay1: "#9399b2",
link: "#89b4fa", link: "#89b4fa",
default: { default: {
background: "#1e1e2e", background: "#1e1e2e",
accent: "#f5c2e7", accent: "#89b4fa",
divider: "#7f849c", divider: "#7f849c",
heading: "#cdd6f4", heading: "#cdd6f4",
paragraph: "#bac2de", paragraph: "#bac2de",

View File

@ -35,7 +35,6 @@ type FontList = Partial<
>; >;
type Mode = { type Mode = {
appbar: Property.Color;
overlay0: Property.Color; overlay0: Property.Color;
overlay1: Property.Color; overlay1: Property.Color;
link: Property.Color; link: Property.Color;

View File

@ -66,7 +66,7 @@ export const ItemGrid = ({
fover: { fover: {
self: focusReset, self: focusReset,
poster: { poster: {
borderColor: (theme) => theme.appbar, borderColor: (theme) => theme.accent,
}, },
title: { title: {
textDecorationLine: "underline", textDecorationLine: "underline",

View File

@ -22,7 +22,7 @@ import { H6, Image, Link, P, Skeleton, ts } from "@kyoo/primitives";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import { Layout, WithLoading } from "../fetch"; import { Layout, WithLoading } from "../fetch";
import { percent, rem, Stylable, useYoshiki } from "yoshiki/native"; import { percent, px, rem, Stylable, Theme, useYoshiki } from "yoshiki/native";
export const episodeDisplayNumber = ( export const episodeDisplayNumber = (
episode: { episode: {
@ -88,6 +88,21 @@ export const EpisodeLine = ({
m: ts(1), m: ts(1),
alignItems: "center", alignItems: "center",
flexDirection: "row", flexDirection: "row",
child: {
poster: {
borderColor: "transparent",
borderWidth: px(4),
},
},
focus: {
poster: {
transform: [{ scale: 1.1 }],
borderColor: (theme: Theme) => theme.accent,
},
title: {
textDecorationLine: "underline",
},
},
}, },
props, props,
)} )}
@ -102,11 +117,15 @@ export const EpisodeLine = ({
width: percent(18), width: percent(18),
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
}} }}
{...css({ flexShrink: 0, m: ts(1) })} {...css(["poster", { flexShrink: 0, m: ts(1) }])}
/> />
<View {...css({ flexGrow: 1, flexShrink: 1, m: ts(1) })}> <View {...css({ flexGrow: 1, flexShrink: 1, m: ts(1) })}>
<Skeleton> <Skeleton>
{isLoading || <H6 aria-level={undefined}>{name ?? t("show.episodeNoMetadata")}</H6>} {isLoading || (
<H6 aria-level={undefined} {...css("title")}>
{name ?? t("show.episodeNoMetadata")}
</H6>
)}
</Skeleton> </Skeleton>
<Skeleton>{isLoading || <P numberOfLines={3}>{overview}</P>}</Skeleton> <Skeleton>{isLoading || <P numberOfLines={3}>{overview}</P>}</Skeleton>
</View> </View>

View File

@ -40,7 +40,15 @@ import {
} from "@kyoo/primitives"; } from "@kyoo/primitives";
import { Fragment } from "react"; import { Fragment } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import {
FlatList,
NativeSyntheticEvent,
Platform,
Pressable,
PressableProps,
TargetedEvent,
View,
} from "react-native";
import { import {
Theme, Theme,
md, md,
@ -160,7 +168,11 @@ const TitleLine = ({
as={Link} as={Link}
href={`/watch/${slug}`} href={`/watch/${slug}`}
color={{ xs: theme.user.colors.black, md: theme.colors.black }} color={{ xs: theme.user.colors.black, md: theme.colors.black }}
{...css({ bg: { xs: theme.user.accent, md: theme.accent } })} hasTVPreferredFocus
{...css({
bg: theme.user.accent,
fover: { self: { bg: theme.user.accent } },
})}
{...tooltip(t("show.play"))} {...tooltip(t("show.play"))}
/> />
<IconButton <IconButton
@ -190,26 +202,33 @@ const TitleLine = ({
}), }),
])} ])}
> >
<P {!Platform.isTV && (
{...css({ <P
color: (theme: Theme) => theme.user.paragraph, {...css({
display: "flex", color: (theme: Theme) => theme.user.paragraph,
})} display: "flex",
> })}
{t("show.studio")}:{" "} >
{isLoading ? ( {t("show.studio")}:{" "}
<Skeleton {...css({ width: rem(5) })} /> {isLoading ? (
) : ( <Skeleton {...css({ width: rem(5) })} />
<A href={`/studio/${studio!.slug}`} {...css({ color: (theme) => theme.user.link })}> ) : (
{studio!.name} <A href={`/studio/${studio!.slug}`} {...css({ color: (theme) => theme.user.link })}>
</A> {studio!.name}
)} </A>
</P> )}
</P>
)}
</View> </View>
</Container> </Container>
); );
}; };
const TvPressable = ({ children, ...props }: PressableProps) => {
if (!Platform.isTV) return <>children</>;
return <Pressable {...props}>{children}</Pressable>;
};
const Description = ({ const Description = ({
isLoading, isLoading,
overview, overview,
@ -225,58 +244,78 @@ const Description = ({
return ( return (
<Container {...css({ flexDirection: { xs: "column", sm: "row" } }, props)}> <Container {...css({ flexDirection: { xs: "column", sm: "row" } }, props)}>
<P {!Platform.isTV && (
{...css({ <P
display: { xs: "flex", sm: "none" }, {...css({
flexWrap: "wrap", display: { xs: "flex", sm: "none" },
color: (theme: Theme) => theme.user.paragraph, flexWrap: "wrap",
})} color: (theme: Theme) => theme.user.paragraph,
> })}
{t("show.genre")}:{" "} >
{(isLoading ? [...Array(3)] : genres!).map((genre, i) => ( {t("show.genre")}:{" "}
<Fragment key={genre?.slug ?? i.toString()}> {(isLoading ? [...Array(3)] : genres!).map((genre, i) => (
<P>{i !== 0 && ", "}</P> <Fragment key={genre?.slug ?? i.toString()}>
{isLoading ? ( <P>{i !== 0 && ", "}</P>
<Skeleton {...css({ width: rem(5) })} /> {isLoading ? (
) : ( <Skeleton {...css({ width: rem(5) })} />
<A href={`/genres/${genre.slug}`}>{genre.name}</A> ) : (
)} <A href={`/genres/${genre.slug}`}>{genre.name}</A>
</Fragment> )}
))} </Fragment>
</P> ))}
</P>
)}
<Skeleton <TvPressable {...css({ focus: { self: { bg: "red" } } })}>
lines={4} <Skeleton
{...css({ width: percent(100), flexBasis: 0, flexGrow: 1, paddingTop: ts(4) })} lines={4}
> {...css({
{isLoading || ( width: percent(100),
<P {...css({ flexBasis: 0, flexGrow: 1, textAlign: "justify", paddingTop: ts(4) })}> flexBasis: 0,
{overview ?? t("show.noOverview")} flexGrow: 1,
</P> paddingTop: Platform.isTV ? 0 : ts(4),
)} })}
</Skeleton> >
<HR {isLoading || (
orientation="vertical" <P
{...css({ marginX: ts(2), display: { xs: "none", sm: "flex" } })} {...css({
/> flexBasis: 0,
<View {...css({ flexBasis: percent(25), display: { xs: "none", sm: "flex" } })}> flexGrow: 1,
<H2>{t("show.genre")}</H2> textAlign: "justify",
{isLoading || genres?.length ? ( paddingTop: Platform.isTV ? 0 : ts(4),
<UL> })}
{(isLoading ? [...Array(3)] : genres!).map((genre, i) => ( >
<LI key={genre?.id ?? i}> {overview ?? t("show.noOverview")}
{isLoading ? ( </P>
<Skeleton {...css({ marginBottom: 0 })} /> )}
) : ( </Skeleton>
<A href={`/genres/${genre.slug}`}>{genre.name}</A> </TvPressable>
)} {!Platform.isTV && (
</LI> <>
))} <HR
</UL> orientation="vertical"
) : ( {...css({ marginX: ts(2), display: { xs: "none", sm: "flex" } })}
<P>{t("show.genre-none")}</P> />
)} <View {...css({ flexBasis: percent(25), display: { xs: "none", sm: "flex" } })}>
</View> <H2>{t("show.genre")}</H2>
{isLoading || genres?.length ? (
<UL>
{(isLoading ? [...Array(3)] : genres!).map((genre, i) => (
<LI key={genre?.id ?? i}>
{isLoading ? (
<Skeleton {...css({ marginBottom: 0 })} />
) : (
<A href={`/genres/${genre.slug}`}>{genre.name}</A>
)}
</LI>
))}
</UL>
) : (
<P>{t("show.genre-none")}</P>
)}
</View>
</>
)}
</Container> </Container>
); );
}; };

View File

@ -18,8 +18,8 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Episode, EpisodeP, QueryIdentifier, Season } from "@kyoo/models"; import { Episode, EpisodeP, QueryIdentifier } from "@kyoo/models";
import { Container, SwitchVariant, ts } from "@kyoo/primitives"; import { Container } from "@kyoo/primitives";
import { Stylable } from "yoshiki/native"; import { Stylable } from "yoshiki/native";
import { View } from "react-native"; import { View } from "react-native";
import { InfiniteFetch } from "../fetch-infinite"; import { InfiniteFetch } from "../fetch-infinite";

View File

@ -20,9 +20,9 @@
import { QueryIdentifier, QueryPage, Show, ShowP } from "@kyoo/models"; import { QueryIdentifier, QueryPage, Show, ShowP } from "@kyoo/models";
import { Platform, View, ViewProps } from "react-native"; import { Platform, View, ViewProps } from "react-native";
import { percent, useYoshiki, vh } from "yoshiki/native"; import { percent, useYoshiki } from "yoshiki/native";
import { DefaultLayout } from "../layout"; import { DefaultLayout } from "../layout";
import { EpisodeList, SeasonTab } from "./season"; import { EpisodeList } from "./season";
import { Header } from "./header"; import { Header } from "./header";
import Svg, { Path, SvgProps } from "react-native-svg"; import Svg, { Path, SvgProps } from "react-native-svg";
import { Container, SwitchVariant } from "@kyoo/primitives"; import { Container, SwitchVariant } from "@kyoo/primitives";

View File

@ -23,7 +23,13 @@ import { Navbar } from "./navbar";
import { useYoshiki } from "yoshiki/native"; import { useYoshiki } from "yoshiki/native";
import { Main } from "@kyoo/primitives"; import { Main } from "@kyoo/primitives";
export const DefaultLayout = ({ page, transparent }: { page: ReactElement, transparent?: boolean }) => { export const DefaultLayout = ({
page,
transparent,
}: {
page: ReactElement;
transparent?: boolean;
}) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
return ( return (
@ -36,6 +42,7 @@ export const DefaultLayout = ({ page, transparent }: { page: ReactElement, trans
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
shadowOpacity: 0,
}, },
)} )}
/> />

View File

@ -146,7 +146,7 @@ export const Navbar = (props: Stylable) => {
<Header <Header
{...css( {...css(
{ {
backgroundColor: (theme) => theme.appbar, backgroundColor: (theme) => theme.accent,
paddingX: ts(2), paddingX: ts(2),
height: { xs: 48, sm: 64 }, height: { xs: 48, sm: 64 },
flexDirection: "row", flexDirection: "row",

View File

@ -10402,7 +10402,7 @@ __metadata:
react-native-svg-transformer: ^1.0.0 react-native-svg-transformer: ^1.0.0
react-native-video: alpha react-native-video: alpha
typescript: ^4.6.3 typescript: ^4.6.3
yoshiki: 1.2.0 yoshiki: 1.2.1
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@ -14144,7 +14144,7 @@ __metadata:
superjson: ^1.11.0 superjson: ^1.11.0
typescript: ^4.9.3 typescript: ^4.9.3
webpack: ^5.75.0 webpack: ^5.75.0
yoshiki: 1.2.0 yoshiki: 1.2.1
zod: ^3.19.1 zod: ^3.19.1
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@ -14519,9 +14519,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"yoshiki@npm:1.2.0": "yoshiki@npm:1.2.1":
version: 1.2.0 version: 1.2.1
resolution: "yoshiki@npm:1.2.0" resolution: "yoshiki@npm:1.2.1"
dependencies: dependencies:
"@types/node": 18.x.x "@types/node": 18.x.x
"@types/react": 18.x.x "@types/react": 18.x.x
@ -14536,7 +14536,7 @@ __metadata:
optional: true optional: true
react-native-web: react-native-web:
optional: true optional: true
checksum: 1ef4bc33563bcf344689a5bfbdc4da1636b99552fcff041ada8fa79224c6c3fac2530a890bf6980981fb7aed9cc4e31b89feb1d0bde920179039fc573935ab42 checksum: 8d5e58f392ca068f13bcb9b87787bf94da2e03077e733db89c40f22aaebe288536b451de2e92fba133d4241771aa87c6cc30d0bea902f94099335e162a146ab5
languageName: node languageName: node
linkType: hard linkType: hard