This commit is contained in:
Ildar Kamalov
2026-06-23 18:49:29 +03:00
parent 6f88dd5731
commit 26938efa43
11 changed files with 107 additions and 44 deletions
+23
View File
@@ -54,4 +54,27 @@ describe('Select', () => {
// The indicator (arrow icon) should be inside the trigger.
expect(indicator).toBeTruthy();
});
it('renders menuFooter inside the dropdown content', async () => {
const user = userEvent.setup();
render(() => (
<Select
options={OPTIONS}
value={OPTIONS[0]}
onChange={() => {}}
menuFooter={<div data-testid="select-footer">Footer</div>}
/>
));
const trigger = document.querySelector(
'[data-scope="select"][data-part="trigger"]',
) as HTMLElement;
await user.click(trigger);
const footer = document.querySelector('[data-testid="select-footer"]');
expect(footer).toBeTruthy();
expect(
footer?.closest('[data-scope="select"][data-part="content"]'),
).toBeTruthy();
});
});
+24 -16
View File
@@ -65,6 +65,8 @@ interface SelectProps<
showIcons?: boolean;
showOptionIcon?: boolean;
optionTestIdPrefix?: string;
/** Optional footer rendered after the scrollable item list inside the dropdown */
menuFooter?: JSX.Element;
}
/**
@@ -243,15 +245,17 @@ export const Select = <
}}
positioning={{
placement:
props.menuPosition === 'right'
? props.menuPlacement === 'top'
? ('top-end' as const)
: ('bottom-end' as const)
: props.menuPlacement === 'top'
? ('top-end' as const)
: ('bottom-end' as const),
props.menuPlacement === 'top'
? ('top-start' as const)
: ('bottom-start' as const),
sameWidth: true,
flip: ['bottom-end', 'top-end'],
fitViewport: true,
flip:
props.menuPlacement === 'auto'
? true
: props.menuPlacement === 'top'
? ['top-start', 'bottom-start']
: ['bottom-start', 'top-start'],
}}
>
<Show when={!props.isDropdownSelect}>
@@ -350,6 +354,7 @@ export const Select = <
</div>
</Show>
</ArkSelect.ItemGroup>
<Show when={props.menuFooter}>{props.menuFooter}</Show>
</Show>
</ArkSelect.Content>
</ArkSelect.Positioner>
@@ -377,15 +382,17 @@ export const Select = <
}}
positioning={{
placement:
props.menuPosition === 'right'
? props.menuPlacement === 'top'
? ('top-end' as const)
: ('bottom-end' as const)
: props.menuPlacement === 'top'
? ('top-end' as const)
: ('bottom-end' as const),
props.menuPlacement === 'top'
? ('top-start' as const)
: ('bottom-start' as const),
sameWidth: true,
flip: ['bottom-end', 'top-end'],
fitViewport: true,
flip:
props.menuPlacement === 'auto'
? true
: props.menuPlacement === 'top'
? ['top-start', 'bottom-start']
: ['bottom-start', 'top-start'],
}}
>
<Show when={!props.isDropdownSelect}>
@@ -502,6 +509,7 @@ export const Select = <
);
}}
</For>
<Show when={props.menuFooter}>{props.menuFooter}</Show>
</div>
</Show>
</ArkCombobox.ItemGroup>
@@ -27,14 +27,8 @@
height: 20px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
cursor: pointer;
flex-shrink: 0;
color: var(--default-gray-icons);
transition: background-color 0.2s;
&:hover {
background-color: var(--hovered-page-background);
}
}
+12 -4
View File
@@ -1,7 +1,12 @@
import { type JSX, Show, untrack } from 'solid-js';
import { A } from '@solidjs/router';
import cn from 'clsx';
import { LinkParams, linkPathBuilder, RoutePathKey } from 'panel/components/Routes/Paths';
import {
LinkParams,
linkPathBuilder,
RoutePathKey,
SCROLL_QUERY_KEY,
} from 'panel/components/Routes/Paths';
import theme from 'panel/lib/theme';
type Props = {
@@ -30,9 +35,12 @@ export const Link = (linkProps: Props) => {
});
const handleClick = (e: MouseEvent) => {
setTimeout(() => {
window.scrollTo({ top: 0 });
}, 100);
// Don't scroll to top when navigating to a specific section (scroll handled by the target page)
if (!linkProps.query?.[SCROLL_QUERY_KEY]) {
setTimeout(() => {
window.scrollTo({ top: 0 });
}, 100);
}
if (linkProps.stop) {
e.stopPropagation();
@@ -7,6 +7,8 @@ import { Switch } from 'panel/common/controls/Switch';
import { Dropdown } from 'panel/common/ui/Dropdown';
import { Select } from 'panel/common/controls/Select';
import { Icon } from 'panel/common/ui/Icon';
import { Link } from 'panel/common/ui/Link';
import { RoutePath, SCROLL_QUERY_KEY } from 'panel/components/Routes/Paths';
import { useIsMobile } from 'panel/hooks/useIsMobile';
import { DISABLE_PROTECTION_TIMINGS, ONE_SECOND_IN_MS } from 'panel/helpers/constants';
import { msToSeconds, msToMinutes, msToHours } from 'panel/helpers/helpers';
@@ -124,6 +126,25 @@ export const Header = (props: Props) => {
</div>
);
const periodSettingsFooter = (
<div class={cn(s.periodSettingsFooter, theme.select.option_check)}>
<Icon icon="settings" class={theme.select.icon} />
<div class={cn(theme.text.t2, theme.text.condenced)}>
{intl.getMessage('period_notify', {
a: (text: string) => (
<Link
to={RoutePath.SettingsPage}
query={{ [SCROLL_QUERY_KEY]: 'stats_config' }}
class={cn(theme.link.link, theme.link.noDecoration)}
>
{text}
</Link>
),
})}
</div>
</div>
);
return (
<div class={s.header}>
<div class={s.headerLeft}>
@@ -217,6 +238,7 @@ export const Header = (props: Props) => {
borderless={!isMobile()}
menuSize="big"
menuPosition="right"
menuFooter={periodSettingsFooter}
/>
</div>
</div>
@@ -75,7 +75,7 @@ export const ListsTable = (props: Props) => {
},
accessor: 'enabled',
sortable: false,
fitContent: true,
width: 64,
className: s.cellNameToggleOuter,
render: (value: boolean, row: Filter) => {
const { name, url, enabled } = row;
+8
View File
@@ -31,6 +31,9 @@ export const RoutePath = {
export type RoutePathKey = keyof typeof RoutePath;
/** Query param key used to pass a target element ID for scroll-to-section navigation. */
export const SCROLL_QUERY_KEY = 'section';
export const Paths: Record<RoutePathKey, string> = {
Dashboard: pathBuilder('dashboard'),
Logs: pathBuilder('logs'),
@@ -63,6 +66,7 @@ export const linkPathBuilder = (
route: RoutePathKey,
params?: LinkParams,
query?: Partial<Record<string, string | number | boolean>>,
hash?: string,
) => {
let path = Paths[route];
if (params) {
@@ -75,5 +79,9 @@ export const linkPathBuilder = (
path += `?${qs.stringify(query)}`;
}
if (hash) {
path += `#${hash}`;
}
return path;
};
+14 -9
View File
@@ -1,6 +1,10 @@
import { createMemo, createEffect, onMount, For, Show, untrack } from 'solid-js';
import cn from 'clsx';
import { SCROLL_QUERY_KEY } from 'panel/components/Routes/Paths';
import { useSearchParams } from '@solidjs/router';
import intl from 'panel/common/intl';
import { Checkbox } from 'panel/common/controls/Checkbox';
import theme from 'panel/lib/theme';
@@ -76,19 +80,20 @@ export const Settings = () => {
);
});
// Handle hash scroll
const [searchParams] = useSearchParams<{ [SCROLL_QUERY_KEY]?: string }>();
// Handle query-param-based scroll to section
createEffect(() => {
if (!isLoading()) {
const hash = window.location.hash;
if (hash) {
try {
const el = document.querySelector(hash);
const section = searchParams[SCROLL_QUERY_KEY];
if (section) {
requestAnimationFrame(() => {
const el = document.getElementById(section);
if (el) {
el.scrollIntoView({ behavior: 'smooth' });
const top = el.getBoundingClientRect().top + window.scrollY - 80;
window.scrollTo({ top, behavior: 'smooth' });
}
} catch {
// Hash is not a valid CSS selector (e.g. route path like #/dashboard)
}
});
}
}
});
@@ -1,10 +1,6 @@
import { createSignal, createEffect, createMemo, untrack } from 'solid-js';
import {
ALL_INTERFACES_IP,
STANDARD_DNS_PORT,
STANDARD_WEB_PORT,
} from 'panel/helpers/constants';
import { ALL_INTERFACES_IP, STANDARD_DNS_PORT, STANDARD_WEB_PORT } from 'panel/helpers/constants';
import { validateInstallPort } from 'panel/helpers/validators';
import type { DnsConfig, SettingsFormValues, WebConfig, ConfigType } from '../types';
+1 -2
View File
@@ -169,7 +169,6 @@ export const saveClient = async (): Promise<boolean> => {
await apiClient.updateClient({ name: state.originalName, data: config });
clearClientForm();
await getClients();
addSuccessToast({ message: intl.getMessage('client_edit_success') });
return true;
} catch (error) {
addErrorToast({ error });
@@ -183,7 +182,7 @@ export const saveClient = async (): Promise<boolean> => {
await apiClient.addClient(config);
clearClientForm();
await getClients();
addSuccessToast({ message: intl.getMessage('client_add_success') });
addSuccessToast({ message: intl.getMessage('client_added') });
return true;
} catch (error) {
addErrorToast({ error });
+1 -1
View File
@@ -267,7 +267,7 @@ export const refreshFilters = async (config: any) => {
setState('processingRefreshFilters', false);
const updated = (data as any)?.updated || 0;
if (updated > 0) {
addSuccessToast(intl.getMessage('list_updated', { count: updated }));
addSuccessToast(intl.getPlural('list_updated', updated));
} else {
addSuccessToast(intl.getMessage('all_lists_up_to_date_toast'));
}