feat: improve consistency and design (#1867)

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Manuel
2025-01-31 23:32:08 +01:00
committed by GitHub
parent 6a9b65f219
commit 5f36d8b125
21 changed files with 216 additions and 99 deletions

View File

@@ -1,5 +1,5 @@
.bannerContainer {
border-radius: 8px;
border-radius: 16px;
overflow: hidden;
@mixin dark {
background: linear-gradient(

View File

@@ -1,11 +1,11 @@
"use client";
import { useRef } from "react";
import Link from "next/link";
import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core";
import type { z } from "zod";
import { useZodForm } from "@homarr/form";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
@@ -14,14 +14,21 @@ import { IconPicker } from "~/components/icons/picker/icon-picker";
type FormType = z.infer<typeof validation.app.manage>;
interface AppFormProps {
submitButtonTranslation: (t: TranslationFunction) => string;
buttonLabels: {
submit: string;
submitAndCreateAnother?: string;
};
initialValues?: FormType;
handleSubmit: (values: FormType) => void;
handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void;
isPending: boolean;
}
export const AppForm = (props: AppFormProps) => {
const { submitButtonTranslation, handleSubmit, initialValues, isPending } = props;
export const AppForm = ({
buttonLabels,
handleSubmit: originalHandleSubmit,
initialValues,
isPending,
}: AppFormProps) => {
const t = useI18n();
const form = useZodForm(validation.app.manage, {
@@ -33,11 +40,23 @@ export const AppForm = (props: AppFormProps) => {
},
});
const shouldCreateAnother = useRef(false);
const handleSubmit = (values: FormType) => {
const redirect = !shouldCreateAnother.current;
const afterSuccess = shouldCreateAnother.current
? () => {
form.reset();
shouldCreateAnother.current = false;
}
: undefined;
originalHandleSubmit(values, redirect, afterSuccess);
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
<IconPicker {...form.getInputProps("iconUrl")} />
<Textarea {...form.getInputProps("description")} label={t("app.field.description.label")} />
<TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
@@ -45,8 +64,19 @@ export const AppForm = (props: AppFormProps) => {
<Button variant="default" component={Link} href="/manage/apps">
{t("common.action.backToOverview")}
</Button>
{buttonLabels.submitAndCreateAnother && (
<Button
type="submit"
onClick={() => {
shouldCreateAnother.current = true;
}}
loading={isPending}
>
{buttonLabels.submitAndCreateAnother}
</Button>
)}
<Button type="submit" loading={isPending}>
{submitButtonTranslation(t)}
{buttonLabels.submit}
</Button>
</Group>
</Stack>

View File

@@ -8,8 +8,7 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { validation } from "@homarr/validation";
import { AppForm } from "../../_form";
@@ -19,14 +18,15 @@ interface AppEditFormProps {
}
export const AppEditForm = ({ app }: AppEditFormProps) => {
const t = useScopedI18n("app.page.edit.notification");
const tScoped = useScopedI18n("app.page.edit.notification");
const t = useI18n();
const router = useRouter();
const { mutate, isPending } = clientApi.app.update.useMutation({
onSuccess: () => {
showSuccessNotification({
title: t("success.title"),
message: t("success.message"),
title: tScoped("success.title"),
message: tScoped("success.message"),
});
void revalidatePathActionAsync("/manage/apps").then(() => {
router.push("/manage/apps");
@@ -34,8 +34,8 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
},
onError: () => {
showErrorNotification({
title: t("error.title"),
message: t("error.message"),
title: tScoped("error.title"),
message: tScoped("error.message"),
});
},
});
@@ -50,11 +50,11 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
[mutate, app.id],
);
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.save"), []);
return (
<AppForm
submitButtonTranslation={submitButtonTranslation}
buttonLabels={{
submit: t("common.action.save"),
}}
initialValues={app}
handleSubmit={handleSubmit}
isPending={isPending}

View File

@@ -7,44 +7,55 @@ import type { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { validation } from "@homarr/validation";
import { AppForm } from "../_form";
export const AppNewForm = () => {
const t = useScopedI18n("app.page.create.notification");
const tScoped = useScopedI18n("app.page.create.notification");
const t = useI18n();
const router = useRouter();
const { mutate, isPending } = clientApi.app.create.useMutation({
onSuccess: () => {
showSuccessNotification({
title: t("success.title"),
message: t("success.message"),
});
void revalidatePathActionAsync("/manage/apps").then(() => {
router.push("/manage/apps");
});
},
onError: () => {
showErrorNotification({
title: t("error.title"),
message: t("error.message"),
title: tScoped("error.title"),
message: tScoped("error.message"),
});
},
});
const handleSubmit = useCallback(
(values: z.infer<typeof validation.app.manage>) => {
mutate(values);
},
[mutate],
);
(values: z.infer<typeof validation.app.manage>, redirect: boolean, afterSuccess?: () => void) => {
mutate(values, {
onSuccess() {
showSuccessNotification({
title: tScoped("success.title"),
message: tScoped("success.message"),
});
afterSuccess?.();
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.create"), []);
if (!redirect) {
return;
}
void revalidatePathActionAsync("/manage/apps").then(() => {
router.push("/manage/apps");
});
},
});
},
[mutate, router, tScoped],
);
return (
<AppForm submitButtonTranslation={submitButtonTranslation} handleSubmit={handleSubmit} isPending={isPending} />
<AppForm
buttonLabels={{
submit: t("common.action.create"),
submitAndCreateAnother: t("common.action.createAnother"),
}}
handleSubmit={handleSubmit}
isPending={isPending}
/>
);
};

View File

@@ -45,7 +45,7 @@ export default async function AppsPage(props: AppsPageProps) {
<Stack>
<Title>{t("page.list.title")}</Title>
<Group justify="space-between" align="center">
<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} />
<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />
{session.user.permissions.includes("app-create") && (
<MobileAffixButton component={Link} href="/manage/apps/new">
{t("page.create.title")}

View File

@@ -67,7 +67,7 @@ const BoardCard = async ({ board }: BoardCardProps) => {
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
return (
<Card withBorder>
<Card radius="lg" withBorder>
<CardSection p="sm" withBorder>
<Group justify="space-between" align="center">
<Group gap="sm">
@@ -106,15 +106,25 @@ const BoardCard = async ({ board }: BoardCardProps) => {
</Group>
</CardSection>
<CardSection p="sm">
<Group wrap="nowrap">
<Button component={Link} href={`/boards/${board.name}`} variant="default" fullWidth>
<CardSection>
<Group gap={0} wrap="nowrap">
<Button
style={{ border: "none", borderRadius: 0 }}
component={Link}
href={`/boards/${board.name}`}
variant="default"
fullWidth
>
{t("action.open.label")}
</Button>
{isMenuVisible && (
<Menu position="bottom-end">
<MenuTarget>
<ActionIcon variant="default" size="lg">
<ActionIcon
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
variant="default"
size="lg"
>
<IconDotsVertical size={16} stroke={1.5} />
</ActionIcon>
</MenuTarget>

View File

@@ -33,6 +33,7 @@ export const IntegrationCreateDropdownContent = () => {
value={search}
data-autofocus
onChange={handleSearch}
variant="filled"
/>
{filteredKinds.length > 0 ? (

View File

@@ -0,0 +1,36 @@
.root {
border-radius: var(--mantine-radius-lg);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.item {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
border: 1px solid transparent;
position: relative;
z-index: 0;
transition: transform 150ms ease;
overflow: hidden;
&[data-first="true"] {
border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0;
}
&[data-last="true"] {
border-radius: 0 0 var(--mantine-radius-lg) var(--mantine-radius-lg);
}
&[data-active] {
transform: scale(1.01);
z-index: 1;
background-color: var(--mantine-color-body);
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
box-shadow: var(--mantine-shadow-md);
border-radius: var(--mantine-radius-lg);
}
}
.chevron {
&[data-rotate] {
transform: rotate(180deg);
}
}

View File

@@ -44,6 +44,7 @@ import { NoResults } from "~/components/no-results";
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
import { DeleteIntegrationActionButton } from "./_integration-buttons";
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
import classes from "./page.module.css";
interface IntegrationsPageProps {
searchParams: Promise<{
@@ -133,7 +134,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />;
}
const grouppedIntegrations = integrations.reduce(
const groupedIntegrations = integrations.reduce(
(acc, integration) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!acc[integration.kind]) {
@@ -147,11 +148,13 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
{} as Record<IntegrationKind, RouterOutputs["integration"]["all"]>,
);
const entries = objectEntries(groupedIntegrations);
return (
<ActiveTabAccordion defaultValue={activeTab} variant="separated">
{objectEntries(grouppedIntegrations).map(([kind, integrations]) => (
<AccordionItem key={kind} value={kind}>
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} />}>
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
{entries.map(([kind, integrations], index) => (
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
<Group>
<Text>{getIntegrationName(kind)}</Text>
<CountBadge count={integrations.length} />

View File

@@ -1,26 +1,26 @@
import type { PropsWithChildren } from "react";
import { AppShellMain } from "@mantine/core";
import {
IconAffiliateFilled,
IconBook2,
IconBox,
IconBrandDiscord,
IconBrandDocker,
IconBrandGithub,
IconBrandTablerFilled,
IconCertificate,
IconClipboardListFilled,
IconDirectionsFilled,
IconGitFork,
IconHome,
IconInfoSmall,
IconLayoutDashboard,
IconLogs,
IconHelpSquareRoundedFilled,
IconHomeFilled,
IconLayoutDashboardFilled,
IconMailForward,
IconPhoto,
IconPlug,
IconQuestionMark,
IconReport,
IconPhotoFilled,
IconPointerFilled,
IconSearch,
IconSettings,
IconTool,
IconUser,
IconSettingsFilled,
IconUserFilled,
IconUsers,
IconUsersGroup,
} from "@tabler/icons-react";
@@ -31,6 +31,7 @@ import { createDocumentationLink } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { MainHeader } from "~/components/layout/header";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import type { NavigationLink } from "~/components/layout/navigation";
import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell";
@@ -41,11 +42,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
const navigationLinks: NavigationLink[] = [
{
label: t("items.home"),
icon: IconHome,
icon: IconHomeFilled,
href: "/manage",
},
{
icon: IconLayoutDashboard,
icon: IconLayoutDashboardFilled,
href: "/manage/boards",
label: t("items.boards"),
},
@@ -54,9 +55,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/apps",
label: t("items.apps"),
hidden: !session,
iconProps: {
strokeWidth: 2.5,
},
},
{
icon: IconPlug,
icon: IconAffiliateFilled,
href: "/manage/integrations",
label: t("items.integrations"),
hidden: !session,
@@ -66,15 +70,18 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/search-engines",
label: t("items.searchEngies"),
hidden: !session,
iconProps: {
strokeWidth: 2.5,
},
},
{
icon: IconPhoto,
icon: IconPhotoFilled,
href: "/manage/medias",
label: t("items.medias"),
hidden: !session,
},
{
icon: IconUser,
icon: IconUserFilled,
label: t("items.users.label"),
hidden: !session?.user.permissions.includes("admin"),
items: [
@@ -98,7 +105,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
},
{
label: t("items.tools.label"),
icon: IconTool,
icon: IconPointerFilled,
// As permissions always include there children permissions, we can check other-view-logs as admin includes it
hidden: !session?.user.permissions.includes("other-view-logs"),
items: [
@@ -110,13 +117,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
},
{
label: t("items.tools.items.api"),
icon: IconPlug,
icon: IconDirectionsFilled,
href: "/manage/tools/api",
hidden: !session?.user.permissions.includes("admin"),
},
{
label: t("items.tools.items.logs"),
icon: IconLogs,
icon: IconBrandTablerFilled,
href: "/manage/tools/logs",
hidden: !session?.user.permissions.includes("other-view-logs"),
},
@@ -128,7 +135,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
},
{
label: t("items.tools.items.tasks"),
icon: IconReport,
icon: IconClipboardListFilled,
href: "/manage/tools/tasks",
hidden: !session?.user.permissions.includes("admin"),
},
@@ -137,12 +144,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{
label: t("items.settings"),
href: "/manage/settings",
icon: IconSettings,
icon: IconSettingsFilled,
hidden: !session?.user.permissions.includes("admin"),
},
{
label: t("items.help.label"),
icon: IconQuestionMark,
icon: IconHelpSquareRoundedFilled,
items: [
{
label: t("items.help.items.documentation"),
@@ -172,7 +179,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
},
{
label: t("items.about"),
icon: IconInfoSmall,
icon: homarrLogoPath,
href: "/manage/about",
},
];

View File

@@ -61,7 +61,7 @@ export default async function GroupsListPage(props: MediaListPageProps) {
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
return (
<ManageContainer size="xl">
<ManageContainer>
<DynamicBreadcrumb />
<Stack>
<Title>{t("media.plural")}</Title>

View File

@@ -83,7 +83,7 @@ export default async function ManagementPage() {
{links.map(
(link) =>
!link.hidden && (
<Card component={Link} href={link.href} key={link.href} withBorder>
<Card component={Link} href={link.href} key={link.href} radius="lg">
<Group justify="space-between" wrap="nowrap">
<Group wrap="nowrap">
<Text size="2.4rem" fw="bolder">

View File

@@ -58,7 +58,7 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
/>
</Grid.Col>
</Grid>
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
<IconPicker {...form.getInputProps("iconUrl")} />
<Fieldset legend={t("search.engine.page.edit.configControl")}>
<SegmentedControl

View File

@@ -45,7 +45,7 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
<Stack>
<Title>{tEngine("page.list.title")}</Title>
<Group justify="space-between" align="center">
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} />
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand />
{session.user.permissions.includes("search-engine-create") && (
<MobileAffixButton component={Link} href="/manage/search-engines/new">
{tEngine("page.create.title")}

View File

@@ -1,5 +1,5 @@
import type { FocusEventHandler } from "react";
import { startTransition, useState } from "react";
import { startTransition } from "react";
import {
ActionIcon,
Box,
@@ -17,7 +17,7 @@ import {
UnstyledButton,
useCombobox,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
import { IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -28,17 +28,27 @@ import { UploadMedia } from "~/app/[locale]/manage/medias/_actions/upload-media"
import classes from "./icon-picker.module.css";
interface IconPickerProps {
initialValue?: string;
value?: string;
onChange: (iconUrl: string) => void;
error?: string | null;
onFocus?: FocusEventHandler;
onBlur?: FocusEventHandler;
}
export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: IconPickerProps) => {
const [value, setValue] = useState<string>(initialValue ?? "");
const [search, setSearch] = useState(initialValue ?? "");
const [previewUrl, setPreviewUrl] = useState<string | null>(initialValue ?? null);
export const IconPicker = ({ value: propsValue, onChange, error, onFocus, onBlur }: IconPickerProps) => {
const [value, setValue] = useUncontrolled({
value: propsValue,
onChange,
});
const [search, setSearch] = useUncontrolled({
value,
onChange: (value) => {
setValue(value);
},
});
const [previewUrl, setPreviewUrl] = useUncontrolled({
value: propsValue ?? null,
});
const { data: session } = useSession();
const tCommon = useScopedI18n("common");
@@ -68,10 +78,9 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
onClick={() => {
const value = item.url;
startTransition(() => {
setValue(value);
setPreviewUrl(value);
setSearch(value);
onChange(value);
setValue(value);
combobox.closeDropdown();
});
}}
@@ -128,7 +137,6 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
setSearch(event.currentTarget.value);
setValue(event.currentTarget.value);
setPreviewUrl(null);
onChange(event.currentTarget.value);
}}
onClick={() => combobox.openDropdown()}
onFocus={(event) => {
@@ -154,7 +162,6 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
setValue(url);
setPreviewUrl(url);
setSearch(url);
onChange(url);
});
}}
>

View File

@@ -20,6 +20,7 @@ export const DesktopSearchInput = () => {
size="sm"
leftSection={<IconSearch size={20} stroke={1.5} />}
onClick={openSpotlight}
radius="xl"
>
{`${t("search.placeholder")}...`}
</TextInput>

View File

@@ -1,7 +1,7 @@
import type { JSX } from "react";
import { AppShellNavbar, AppShellSection, ScrollArea } from "@mantine/core";
import { AppShellNavbar, AppShellSection, Image, ScrollArea } from "@mantine/core";
import type { TablerIcon } from "@homarr/ui";
import type { TablerIcon, TablerIconProps } from "@homarr/ui";
import type { ClientNavigationLink } from "./navigation-link";
import { CommonNavLink } from "./navigation-link";
@@ -27,8 +27,13 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
return null;
}
const { icon: TablerIcon, ...props } = link;
const Icon = <TablerIcon size={20} stroke={1.5} />;
const { icon: TablerIcon, iconProps, ...props } = link;
const Icon =
typeof TablerIcon === "string" ? (
<Image src={TablerIcon} w={20} h={20} />
) : (
<TablerIcon size={20} stroke={1.5} {...iconProps} />
);
let clientLink: ClientNavigationLink;
if ("items" in props) {
clientLink = {
@@ -38,7 +43,7 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
.map((item) => {
return {
...item,
icon: <item.icon size={20} stroke={1.5} />,
icon: <item.icon size={20} stroke={1.5} {...iconProps} />,
};
}),
} as ClientNavigationLink;
@@ -55,7 +60,8 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
interface CommonNavigationLinkProps {
label: string;
icon: TablerIcon;
icon: TablerIcon | string;
iconProps?: TablerIconProps;
hidden?: boolean;
}

View File

@@ -805,6 +805,7 @@
"apply": "Apply",
"backToOverview": "Back to overview",
"create": "Create",
"createAnother": "Create and start over",
"edit": "Edit",
"import": "Import",
"insert": "Insert",

View File

@@ -1,5 +1,6 @@
import type { Icon123 } from "@tabler/icons-react";
import type { Icon123, IconProps } from "@tabler/icons-react";
export * from "./src";
export type TablerIcon = typeof Icon123;
export type TablerIconProps = IconProps;

View File

@@ -1,4 +1,4 @@
import type { MantineSize } from "@mantine/core";
import type { MantineRadius, MantineSize } from "@mantine/core";
import { Avatar } from "@mantine/core";
import type { IntegrationKind } from "@homarr/definitions";
@@ -7,13 +7,14 @@ import { getIconUrl } from "@homarr/definitions";
interface IntegrationAvatarProps {
size: MantineSize;
kind: IntegrationKind | null;
radius?: MantineRadius;
}
export const IntegrationAvatar = ({ kind, size }: IntegrationAvatarProps) => {
export const IntegrationAvatar = ({ kind, size, radius }: IntegrationAvatarProps) => {
const url = kind ? getIconUrl(kind) : null;
if (!url) {
return null;
}
return <Avatar size={size} src={url} />;
return <Avatar size={size} src={url} radius={radius} styles={{ image: { objectFit: "contain" } }} />;
};

View File

@@ -10,9 +10,10 @@ import { IconSearch } from "@tabler/icons-react";
interface SearchInputProps {
defaultValue?: string;
placeholder: string;
flexExpand?: boolean;
}
export const SearchInput = ({ placeholder, defaultValue }: SearchInputProps) => {
export const SearchInput = ({ placeholder, defaultValue, flexExpand = false }: SearchInputProps) => {
// eslint-disable-next-line @typescript-eslint/unbound-method
const { replace } = useRouter();
const pathName = usePathname();
@@ -40,6 +41,7 @@ export const SearchInput = ({ placeholder, defaultValue }: SearchInputProps) =>
defaultValue={defaultValue}
onChange={handleSearch}
placeholder={placeholder}
style={{ flex: flexExpand ? "1" : undefined }}
/>
);
};