переезд на панель
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
|
||||||
Generated
+13
@@ -17,6 +17,7 @@
|
|||||||
"@microsoft/sp-property-pane": "1.22.1",
|
"@microsoft/sp-property-pane": "1.22.1",
|
||||||
"@microsoft/sp-webpart-base": "1.22.1",
|
"@microsoft/sp-webpart-base": "1.22.1",
|
||||||
"@pnp/sp": "^4.20.0",
|
"@pnp/sp": "^4.20.0",
|
||||||
|
"@tnsu/ui-kit": "^0.4.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
"tslib": "2.3.1"
|
"tslib": "2.3.1"
|
||||||
@@ -6281,6 +6282,18 @@
|
|||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"node_modules/@tnsu/ui-kit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.4.0/ui-kit-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-J3cBvoR2vV0SC5GihOagLAqvC0txQE342qIh6viabMt+Um6Jg4mhTff+ZkVahG0g+bF6sqSCUl3x813aO82KuQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fluentui/react": "^8.106.4",
|
||||||
|
"react": "^17.0.1",
|
||||||
|
"react-dom": "^17.0.1",
|
||||||
|
"tslib": "^2.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tootallnate/once": {
|
"node_modules/@tootallnate/once": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz",
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fluentui/react": "^8.106.4",
|
"@fluentui/react": "^8.106.4",
|
||||||
"@pnp/sp": "^4.20.0",
|
|
||||||
"@microsoft/sp-component-base": "1.22.1",
|
"@microsoft/sp-component-base": "1.22.1",
|
||||||
"@microsoft/sp-core-library": "1.22.1",
|
"@microsoft/sp-core-library": "1.22.1",
|
||||||
"@microsoft/sp-http": "1.22.1",
|
"@microsoft/sp-http": "1.22.1",
|
||||||
@@ -21,6 +20,8 @@
|
|||||||
"@microsoft/sp-office-ui-fabric-core": "1.22.1",
|
"@microsoft/sp-office-ui-fabric-core": "1.22.1",
|
||||||
"@microsoft/sp-property-pane": "1.22.1",
|
"@microsoft/sp-property-pane": "1.22.1",
|
||||||
"@microsoft/sp-webpart-base": "1.22.1",
|
"@microsoft/sp-webpart-base": "1.22.1",
|
||||||
|
"@pnp/sp": "^4.20.0",
|
||||||
|
"@tnsu/ui-kit": "^0.4.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
"tslib": "2.3.1"
|
"tslib": "2.3.1"
|
||||||
|
|||||||
@@ -1,23 +1,63 @@
|
|||||||
@import '../../../../styles/design-tokens';
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Раскладка — тот же боковой рейл + основная область, что и в request-portal
|
||||||
|
// (@tnsu/ui-kit Sidebar, см. RequestPortal.module.scss): единый стиль
|
||||||
|
// навигации во всех веб-частях Tnsu. В отличие от request-portal (рейл во всю
|
||||||
|
// высоту окна, edge-to-edge), здесь виджет — ограниченная карточка на странице
|
||||||
|
// SharePoint, поэтому без sticky/100vh-трюков: рейл просто растягивается по
|
||||||
|
// высоте своего соседа (.main) через align-items: stretch.
|
||||||
.shell {
|
.shell {
|
||||||
background: linear-gradient(180deg, #F6F8FA 0%, #EEF2F6 100%);
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
border-radius: $r-lg;
|
border-radius: $r-lg;
|
||||||
border: 1px solid $gray-200;
|
border: 1px solid $gray-200;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: $sh-1;
|
box-shadow: $sh-1;
|
||||||
color: $gray-900;
|
|
||||||
font-family: $font-sans;
|
font-family: $font-sans;
|
||||||
font-size: $fs-body;
|
font-size: $fs-body;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
background: $navy-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0; // чтобы контент не распирал колонку
|
||||||
|
background: linear-gradient(180deg, #F6F8FA 0%, #EEF2F6 100%);
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: 28px 30px 36px;
|
padding: 28px 30px 36px;
|
||||||
max-width: 1080px;
|
max-width: 1080px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Мобильная кнопка-бургер (открывает выезжающий Sidebar). На десктопе скрыта.
|
||||||
|
.mobileNav {
|
||||||
|
display: none;
|
||||||
|
padding: $sp-4 $sp-6 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// < 768px: рейл сам уходит в position:fixed и уезжает за левый край экрана,
|
||||||
|
// пока не открыт (см. @tnsu/ui-kit Sidebar — это его собственный CSS, чисто
|
||||||
|
// media-query, без участия JS) — НЕ display:none на .side: рейл вёрстке всё
|
||||||
|
// равно нужен в потоке, чтобы при mobileOpen выехать поверх контента.
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.mobileNav {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.centerState {
|
.centerState {
|
||||||
padding: 60px 30px;
|
padding: 60px 30px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './AdminApp.module.scss';
|
import styles from './AdminApp.module.scss';
|
||||||
import { TopNav } from './TopNav';
|
import '@tnsu/ui-kit/styles.css';
|
||||||
|
import { IconSprite, Sidebar, SidebarToggle } from '@tnsu/ui-kit';
|
||||||
|
import type { ISidebarItem } from '@tnsu/ui-kit';
|
||||||
import { ScreenKey } from './types';
|
import { ScreenKey } from './types';
|
||||||
import { ApprovalRouteApi } from '../../services/api';
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||||
@@ -8,6 +10,7 @@ import { SharePointListsClient } from '../../services/sharePointLists';
|
|||||||
import { RequestManageService } from '../../services/requestManageService';
|
import { RequestManageService } from '../../services/requestManageService';
|
||||||
import { RequestType, TemplateRoute } from '../../models';
|
import { RequestType, TemplateRoute } from '../../models';
|
||||||
import { navigate, useRoute } from '../../routing';
|
import { navigate, useRoute } from '../../routing';
|
||||||
|
import { texts } from '../../texts';
|
||||||
import { DashboardScreen } from '../screens/DashboardScreen';
|
import { DashboardScreen } from '../screens/DashboardScreen';
|
||||||
import { RequestTypesScreen } from '../screens/RequestTypesScreen';
|
import { RequestTypesScreen } from '../screens/RequestTypesScreen';
|
||||||
import { RoutesScreen } from '../screens/RoutesScreen';
|
import { RoutesScreen } from '../screens/RoutesScreen';
|
||||||
@@ -15,6 +18,22 @@ import { RouteStepsScreen } from '../screens/RouteStepsScreen';
|
|||||||
import { ResolveScreen } from '../screens/ResolveScreen';
|
import { ResolveScreen } from '../screens/ResolveScreen';
|
||||||
import { ManageRequestsScreen } from '../screens/ManageRequestsScreen';
|
import { ManageRequestsScreen } from '../screens/ManageRequestsScreen';
|
||||||
|
|
||||||
|
// Тот же боковой рейл (@tnsu/ui-kit Sidebar), что и в request-portal — единый
|
||||||
|
// стиль навигации во всех веб-частях Tnsu. 'routeSteps' сюда не входит: это не
|
||||||
|
// отдельная вкладка URL, а состояние вкладки 'routes' при открытых шагах
|
||||||
|
// конкретного маршрута (см. routing.ts) — подсвечиваем в этом случае 'routes'.
|
||||||
|
const NAV_ITEMS: ISidebarItem[] = [
|
||||||
|
{ id: 'dashboard', title: texts.nav.dashboard, icon: 'chart' },
|
||||||
|
{ id: 'requestTypes', title: texts.nav.requestTypes, icon: 'document' },
|
||||||
|
{ id: 'routes', title: texts.nav.routes, icon: 'send' },
|
||||||
|
{ id: 'resolve', title: texts.nav.resolve, icon: 'search' },
|
||||||
|
{ id: 'manageRequests', title: texts.nav.manageRequests, icon: 'clipboard' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function sidebarActiveId(tab: ScreenKey): string {
|
||||||
|
return tab === 'routeSteps' ? 'routes' : tab;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IAdminAppProps {
|
export interface IAdminAppProps {
|
||||||
api: ApprovalRouteApi;
|
api: ApprovalRouteApi;
|
||||||
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||||
@@ -28,8 +47,13 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const [selectedRoute, setSelectedRoute] = React.useState<TemplateRoute | undefined>();
|
const [selectedRoute, setSelectedRoute] = React.useState<TemplateRoute | undefined>();
|
||||||
const [selectedRequestType, setSelectedRequestType] = React.useState<RequestType | undefined>();
|
const [selectedRequestType, setSelectedRequestType] = React.useState<RequestType | undefined>();
|
||||||
|
const [navCollapsed, setNavCollapsed] = React.useState(false);
|
||||||
|
const [mobileNavOpen, setMobileNavOpen] = React.useState(false);
|
||||||
|
|
||||||
const navigateTab = (key: ScreenKey): void => navigate({ tab: key });
|
const navigateTab = (key: ScreenKey): void => {
|
||||||
|
setMobileNavOpen(false);
|
||||||
|
navigate({ tab: key });
|
||||||
|
};
|
||||||
|
|
||||||
const openSteps = (r: TemplateRoute): void => {
|
const openSteps = (r: TemplateRoute): void => {
|
||||||
setSelectedRoute(r);
|
setSelectedRoute(r);
|
||||||
@@ -76,41 +100,58 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.shell}>
|
<div className={styles.shell}>
|
||||||
<TopNav active={route.tab} onSelect={navigateTab} />
|
<IconSprite />
|
||||||
<div className={styles.content}>
|
<div className={styles.side}>
|
||||||
{route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />}
|
<Sidebar
|
||||||
{route.tab === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
|
items={NAV_ITEMS}
|
||||||
{route.tab === 'routes' && !route.routeId && (
|
activeId={sidebarActiveId(route.tab)}
|
||||||
<RoutesScreen
|
onChange={id => navigateTab(id as ScreenKey)}
|
||||||
api={api}
|
brand={texts.nav.title}
|
||||||
dictionary={dictionary}
|
collapsed={navCollapsed}
|
||||||
initialFilterRequestTypeId={route.routesFilter}
|
onToggleCollapsed={() => setNavCollapsed(v => !v)}
|
||||||
onOpenSteps={openSteps}
|
mobileOpen={mobileNavOpen}
|
||||||
/>
|
onMobileClose={() => setMobileNavOpen(false)}
|
||||||
)}
|
/>
|
||||||
{route.tab === 'routes' && route.routeId && selectedRoute && (
|
|
||||||
<RouteStepsScreen
|
|
||||||
api={api}
|
|
||||||
route={selectedRoute}
|
|
||||||
requestType={selectedRequestType}
|
|
||||||
dictionary={dictionary}
|
|
||||||
sharePoint={sharePoint}
|
|
||||||
onBack={() => navigate({ tab: 'routes', routesFilter: route.routesFilter })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{route.tab === 'resolve' && (
|
|
||||||
<ResolveScreen
|
|
||||||
api={api}
|
|
||||||
dictionary={dictionary}
|
|
||||||
sharePoint={sharePoint}
|
|
||||||
currentUserEmail={currentUserEmail}
|
|
||||||
route={route.resolve}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{route.tab === 'manageRequests' && (
|
|
||||||
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<main className={styles.main}>
|
||||||
|
<div className={styles.mobileNav}>
|
||||||
|
<SidebarToggle onClick={() => setMobileNavOpen(true)} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.content}>
|
||||||
|
{route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />}
|
||||||
|
{route.tab === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
|
||||||
|
{route.tab === 'routes' && !route.routeId && (
|
||||||
|
<RoutesScreen
|
||||||
|
api={api}
|
||||||
|
dictionary={dictionary}
|
||||||
|
initialFilterRequestTypeId={route.routesFilter}
|
||||||
|
onOpenSteps={openSteps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{route.tab === 'routes' && route.routeId && selectedRoute && (
|
||||||
|
<RouteStepsScreen
|
||||||
|
api={api}
|
||||||
|
route={selectedRoute}
|
||||||
|
requestType={selectedRequestType}
|
||||||
|
dictionary={dictionary}
|
||||||
|
sharePoint={sharePoint}
|
||||||
|
onBack={() => navigate({ tab: 'routes', routesFilter: route.routesFilter })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{route.tab === 'resolve' && (
|
||||||
|
<ResolveScreen
|
||||||
|
api={api}
|
||||||
|
dictionary={dictionary}
|
||||||
|
sharePoint={sharePoint}
|
||||||
|
currentUserEmail={currentUserEmail}
|
||||||
|
route={route.resolve}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{route.tab === 'manageRequests' && (
|
||||||
|
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
@import '../../../../styles/design-tokens';
|
|
||||||
|
|
||||||
.topnav {
|
|
||||||
background: $grad-header;
|
|
||||||
padding: 0 22px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 54px;
|
|
||||||
box-shadow: 0 1px 0 rgba(212,160,30,.35);
|
|
||||||
border-radius: $r-md $r-md 0 0;
|
|
||||||
// Без переноса: высота шапки фиксирована (54px), поэтому перенесённая
|
|
||||||
// строка вкладок вылезала бы за её пределы вместе с подчёркиванием.
|
|
||||||
// Вместо переноса вкладки скроллятся по горизонтали.
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-right: 20px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brandText {
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
|
||||||
letter-spacing: .4px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
// min-width: 0 обязателен — иначе flex-элемент не сожмётся уже своего
|
|
||||||
// содержимого и overflow-x никогда не сработает.
|
|
||||||
min-width: 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
// Делает контейнер offsetParent'ом для вкладок: автопрокрутка в TopNav.tsx
|
|
||||||
// считает offsetLeft относительно него. Без этого offsetLeft отсчитывался
|
|
||||||
// бы от body и прокрутка уезжала бы не туда.
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
// Тонкая полупрозрачная полоса — не спрятана полностью: с добавлением новых
|
|
||||||
// вкладок (сейчас их 6) они не всегда помещаются в узкую панель, и без
|
|
||||||
// видимого скроллбара непонятно, что часть вкладок обрезана, а не пропала.
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgba(255, 255, 255, .35) transparent;
|
|
||||||
&::-webkit-scrollbar { height: 4px; }
|
|
||||||
&::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
&::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, .35); border-radius: 2px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
flex-shrink: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
padding: 0 13px;
|
|
||||||
border: none;
|
|
||||||
background: 0;
|
|
||||||
color: rgba(255,255,255,.5);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: .18s;
|
|
||||||
height: 54px;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
letter-spacing: .2px;
|
|
||||||
|
|
||||||
&:hover { color: rgba(255,255,255,.85); }
|
|
||||||
&.on { color: $gold-300; border-bottom-color: $gold-400; }
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import styles from './TopNav.module.scss';
|
|
||||||
import { ScreenKey } from './types';
|
|
||||||
import { texts } from '../../texts';
|
|
||||||
|
|
||||||
interface ITab {
|
|
||||||
key: ScreenKey;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TABS: ITab[] = [
|
|
||||||
{ key: 'dashboard', label: texts.nav.dashboard },
|
|
||||||
{ key: 'requestTypes', label: texts.nav.requestTypes },
|
|
||||||
{ key: 'routes', label: texts.nav.routes },
|
|
||||||
{ key: 'resolve', label: texts.nav.resolve },
|
|
||||||
{ key: 'manageRequests', label: texts.nav.manageRequests }
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface ITopNavProps {
|
|
||||||
active: ScreenKey;
|
|
||||||
onSelect: (key: ScreenKey) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTabActive(active: ScreenKey, tabKey: ScreenKey): boolean {
|
|
||||||
// Экран шагов маршрута — вложенный, подсвечиваем вкладку «Маршруты».
|
|
||||||
return active === tabKey || (active === 'routeSteps' && tabKey === 'routes');
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TopNav: React.FC<ITopNavProps> = ({ active, onSelect }) => {
|
|
||||||
const tabsRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
const activeTabRef = React.useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
// Вкладки скроллятся по горизонтали, поэтому активная может оказаться за
|
|
||||||
// краем. Подкручиваем контейнер вручную (не scrollIntoView — тот способен
|
|
||||||
// утащить за собой прокрутку всей страницы SharePoint).
|
|
||||||
React.useEffect(() => {
|
|
||||||
const reveal = (): void => {
|
|
||||||
const container = tabsRef.current;
|
|
||||||
const tab = activeTabRef.current;
|
|
||||||
if (!container || !tab) return;
|
|
||||||
|
|
||||||
const left = tab.offsetLeft;
|
|
||||||
const width = tab.offsetWidth;
|
|
||||||
|
|
||||||
if (width > container.clientWidth) {
|
|
||||||
// Вкладка шире видимой полосы — показываем её начало, чтобы подпись
|
|
||||||
// читалась с первого слова.
|
|
||||||
container.scrollLeft = left;
|
|
||||||
} else if (left < container.scrollLeft) {
|
|
||||||
container.scrollLeft = left;
|
|
||||||
} else if (left + width > container.scrollLeft + container.clientWidth) {
|
|
||||||
container.scrollLeft = left + width - container.clientWidth;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
reveal();
|
|
||||||
|
|
||||||
// Шрифты (Lora/Inter) подключаются рантаймом: пока они не применились,
|
|
||||||
// ширины вкладок другие и позиция прокрутки получается устаревшей.
|
|
||||||
const fonts = (document as Document & { fonts?: FontFaceSet }).fonts;
|
|
||||||
let cancelled = false;
|
|
||||||
if (fonts?.ready) {
|
|
||||||
fonts.ready.then(() => { if (!cancelled) reveal(); }).catch(() => { /* не критично */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('resize', reveal);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
window.removeEventListener('resize', reveal);
|
|
||||||
};
|
|
||||||
}, [active]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className={styles.topnav}>
|
|
||||||
<div className={styles.brand}>
|
|
||||||
<div className={styles.brandText}>{texts.nav.title}</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.tabs} ref={tabsRef}>
|
|
||||||
{TABS.map(tab => {
|
|
||||||
const on = isTabActive(active, tab.key);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab.key}
|
|
||||||
ref={on ? activeTabRef : undefined}
|
|
||||||
className={`${styles.tab} ${on ? styles.on : ''}`}
|
|
||||||
onClick={() => onSelect(tab.key)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user