@redacto.io/consent-sdk-react 7.3.0 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/chunk-6A665OPR.mjs +30 -0
  3. package/dist/index.mjs +85 -108
  4. package/dist/privacy-center.js +3519 -3281
  5. package/dist/privacy-center.mjs +3521 -3295
  6. package/package.json +1 -1
  7. package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +3 -3
  8. package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +204 -0
  9. package/src/RedactoPrivacyCenter/components/AppShell/MobileNavDrawer.tsx +218 -0
  10. package/src/RedactoPrivacyCenter/components/AppShell/NavItem.tsx +205 -0
  11. package/src/RedactoPrivacyCenter/components/AppShell/PageHeader.tsx +107 -0
  12. package/src/RedactoPrivacyCenter/components/AppShell/SidebarNav.tsx +100 -0
  13. package/src/RedactoPrivacyCenter/components/AppShell/TopAppBar.tsx +338 -0
  14. package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +224 -0
  15. package/src/RedactoPrivacyCenter/components/AppShell/hooks/useIsTruncated.ts +28 -0
  16. package/src/RedactoPrivacyCenter/components/AppShell/hooks/useKeyboardShortcut.ts +40 -0
  17. package/src/RedactoPrivacyCenter/components/AppShell/hooks/useSidebarState.ts +62 -0
  18. package/src/RedactoPrivacyCenter/components/Form.tsx +1 -5
  19. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +5 -27
  20. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/types.ts +0 -19
  21. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +42 -5
  22. package/src/RedactoPrivacyCenter/ui/PCPopover.tsx +13 -2
  23. package/src/RedactoPrivacyCenter/components/PrivacyCenterLayout.tsx +0 -350
  24. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/SideBarPC.tsx +0 -489
  25. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/SideMenuPC.tsx +0 -408
  26. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/menuItemsPC.tsx +0 -1
  27. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/utils.ts +0 -38
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redacto.io/consent-sdk-react",
3
- "version": "7.3.0",
3
+ "version": "8.1.0",
4
4
  "main": "dist/index.cjs",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -9,7 +9,7 @@ import { useBaseUrl } from './context/BaseUrlContext';
9
9
  import { useAuth } from './context/AuthContext';
10
10
  import { PCLoader } from './ui';
11
11
  import { pcStyles } from './styles/pcStyles';
12
- import PrivacyCenterLayoutComponent from './components/PrivacyCenterLayout';
12
+ import AppShell from './components/AppShell/AppShell';
13
13
 
14
14
  interface PrivacyCenterLayoutProps {
15
15
  children: ReactNode;
@@ -161,9 +161,9 @@ export function PrivacyCenterLayout({ children, slug, onBack }: PrivacyCenterLay
161
161
 
162
162
  return (
163
163
  <OrganizationProvider organization={organization}>
164
- <PrivacyCenterLayoutComponent onBack={onBack}>
164
+ <AppShell onBack={onBack}>
165
165
  {children}
166
- </PrivacyCenterLayoutComponent>
166
+ </AppShell>
167
167
  </OrganizationProvider>
168
168
  );
169
169
  }
@@ -0,0 +1,204 @@
1
+ import {
2
+ Suspense,
3
+ useCallback,
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ type CSSProperties,
8
+ } from 'react';
9
+ import { useTranslation } from 'react-i18next';
10
+ import { LocaleContext } from '../../context/LocaleContext';
11
+ import { pcStyles } from '../../styles/pcStyles';
12
+ import { getVar } from '../../lib/utils';
13
+ import Footer from '../../ui/Footer';
14
+ import i18n from '../../lib/i18n';
15
+ import { useMediaQuery } from '../../../RedactoNoticeConsent/useMediaQuery';
16
+ import { useSidebarState } from './hooks/useSidebarState';
17
+ import { useKeyboardShortcut } from './hooks/useKeyboardShortcut';
18
+ import { TopAppBar } from './TopAppBar';
19
+ import { SidebarNav } from './SidebarNav';
20
+ import { MobileNavDrawer } from './MobileNavDrawer';
21
+ import { PageHeader } from './PageHeader';
22
+ import type { PrivacyCenterLayoutProps } from '../types';
23
+
24
+ export default function AppShell({
25
+ children,
26
+ sidebarClassName,
27
+ contentClassName,
28
+ onBack,
29
+ }: PrivacyCenterLayoutProps) {
30
+ const { t } = useTranslation();
31
+ const sidebar = useSidebarState();
32
+ const [locale, setLocale] = useState(i18n.language);
33
+ const [isMobileNavOpen, setMobileNavOpen] = useState(false);
34
+ const hamburgerRef = useRef<HTMLButtonElement>(null);
35
+
36
+ const isMobile = useMediaQuery('(max-width: 639px)');
37
+
38
+ // Close mobile drawer when crossing the breakpoint
39
+ useEffect(() => {
40
+ if (!isMobile && isMobileNavOpen) setMobileNavOpen(false);
41
+ }, [isMobile, isMobileNavOpen]);
42
+
43
+ useEffect(() => {
44
+ const onChange = () => setLocale(i18n.language);
45
+ i18n.on('languageChanged', onChange);
46
+ return () => i18n.off('languageChanged', onChange);
47
+ }, []);
48
+
49
+ const handleSidebarToggle = useCallback(() => {
50
+ sidebar.toggle();
51
+ }, [sidebar]);
52
+
53
+ // Cmd/Ctrl+B toggles sidebar (or mobile drawer on small screens)
54
+ useKeyboardShortcut(
55
+ { key: 'b', mod: true },
56
+ useCallback(() => {
57
+ if (isMobile) setMobileNavOpen((prev) => !prev);
58
+ else sidebar.toggle();
59
+ }, [isMobile, sidebar]),
60
+ );
61
+
62
+ const rootStyle: CSSProperties = {
63
+ display: 'flex',
64
+ flexDirection: 'column',
65
+ minHeight: '100vh',
66
+ background: getVar('--pc-bg-shell'),
67
+ position: 'relative',
68
+ };
69
+
70
+ const bodyStyle: CSSProperties = {
71
+ display: 'flex',
72
+ flex: 1,
73
+ minHeight: 0,
74
+ position: 'relative',
75
+ };
76
+
77
+ const mainStyle: CSSProperties = {
78
+ flex: 1,
79
+ minWidth: 0,
80
+ display: 'flex',
81
+ flexDirection: 'column',
82
+ background: getVar('--pc-bg-shell'),
83
+ };
84
+
85
+ const contentWrapperStyle: CSSProperties = {
86
+ flex: 1,
87
+ display: 'flex',
88
+ flexDirection: 'column',
89
+ minHeight: 0,
90
+ padding: 'var(--pc-shell-content-padding, 0 24px)',
91
+ boxSizing: 'border-box',
92
+ };
93
+
94
+ return (
95
+ <LocaleContext.Provider value={{ locale, setLocale }}>
96
+ <Suspense
97
+ fallback={
98
+ <div
99
+ style={{
100
+ ...pcStyles.flex,
101
+ ...pcStyles.itemsCenter,
102
+ ...pcStyles.justifyCenter,
103
+ minHeight: '100vh',
104
+ }}
105
+ >
106
+ {t('loadingEllipsis')}
107
+ </div>
108
+ }
109
+ >
110
+ <main className="pc-root pc-shell-root" style={rootStyle}>
111
+ <TopAppBar
112
+ ref={hamburgerRef}
113
+ showHamburger={isMobile}
114
+ onOpenMobileNav={() => setMobileNavOpen(true)}
115
+ locale={locale}
116
+ onLocaleChange={setLocale}
117
+ onBack={onBack}
118
+ />
119
+ <div className="pc-shell-body" style={bodyStyle}>
120
+ {!isMobile && (
121
+ <SidebarNav
122
+ className={sidebarClassName}
123
+ isExpanded={sidebar.isExpanded}
124
+ onToggle={handleSidebarToggle}
125
+ />
126
+ )}
127
+ <section className="pc-shell-content" style={mainStyle}>
128
+ <div className={contentClassName} style={contentWrapperStyle}>
129
+ <PageHeader />
130
+ <div style={{ flex: 1, minWidth: 0, width: '100%' }}>{children}</div>
131
+ <div
132
+ style={{
133
+ ...pcStyles.flex,
134
+ width: '100%',
135
+ ...pcStyles.itemsCenter,
136
+ ...pcStyles.justifyCenter,
137
+ paddingTop: 12,
138
+ paddingBottom: 12,
139
+ }}
140
+ >
141
+ <Footer />
142
+ </div>
143
+ </div>
144
+ </section>
145
+ </div>
146
+ {isMobile && (
147
+ <MobileNavDrawer
148
+ isOpen={isMobileNavOpen}
149
+ onClose={() => setMobileNavOpen(false)}
150
+ returnFocusRef={hamburgerRef}
151
+ />
152
+ )}
153
+ </main>
154
+ <style>{`
155
+ .pc-shell-sidebar {
156
+ transition: width 200ms cubic-bezier(0.4, 0, 0.2, 1);
157
+ }
158
+ .pc-shell-nav-item:focus-visible {
159
+ box-shadow: 0 0 0 2px var(--pc-border-color-focus);
160
+ }
161
+ .pc-shell-sidebar-toggle {
162
+ opacity: 0;
163
+ transition: opacity 200ms ease;
164
+ }
165
+ .pc-shell-sidebar:hover .pc-shell-sidebar-toggle,
166
+ .pc-shell-sidebar-toggle:hover,
167
+ .pc-shell-sidebar-toggle:focus-visible {
168
+ opacity: 1;
169
+ }
170
+ @media (prefers-reduced-motion: reduce) {
171
+ .pc-shell-sidebar-toggle { transition: none !important; }
172
+ }
173
+ .pc-shell-user-menu-header {
174
+ background: linear-gradient(135deg, var(--pc-bg-secondary) 0%, var(--pc-bg-shell) 100%);
175
+ background: linear-gradient(
176
+ 135deg,
177
+ color-mix(in srgb, var(--pc-primary) 6%, var(--pc-bg-shell)) 0%,
178
+ var(--pc-bg-shell) 100%
179
+ );
180
+ }
181
+ .pc-popover.pc-shell-user-menu-popover {
182
+ border-radius: 12px !important;
183
+ overflow: hidden !important;
184
+ padding: 0 !important;
185
+ background: var(--pc-bg-primary) !important;
186
+ }
187
+ @media (prefers-reduced-motion: reduce) {
188
+ .pc-shell-sidebar { transition: none !important; }
189
+ .pc-shell-nav-item-icon { transform: none !important; transition: none !important; }
190
+ }
191
+ @media (max-width: 639px) {
192
+ :root { --pc-shell-content-padding: 0 12px; --pc-shell-pageheader-padding: 14px 0 12px; }
193
+ }
194
+ @media (min-width: 640px) and (max-width: 1023px) {
195
+ :root { --pc-shell-content-padding: 0 20px; --pc-shell-pageheader-padding: 18px 0 14px; }
196
+ }
197
+ @media (min-width: 1024px) {
198
+ :root { --pc-shell-content-padding: 0 32px; --pc-shell-pageheader-padding: 22px 0 16px; }
199
+ }
200
+ `}</style>
201
+ </Suspense>
202
+ </LocaleContext.Provider>
203
+ );
204
+ }
@@ -0,0 +1,218 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ type CSSProperties,
7
+ type RefObject,
8
+ } from 'react';
9
+ import { createPortal } from 'react-dom';
10
+ import { useTranslation } from 'react-i18next';
11
+ import { X } from 'lucide-react';
12
+ import { PCActionIcon, PCAvatar } from '../../ui';
13
+ import { useMenuItemsPC } from '../../lib/constants';
14
+ import { useBranding } from '../../context/BrandingContext';
15
+ import { useOrganization } from '../../context/OrganizationContext';
16
+ import { getVar } from '../../lib/utils';
17
+ import { NavItem } from './NavItem';
18
+
19
+ type MobileNavDrawerProps = {
20
+ isOpen: boolean;
21
+ onClose: () => void;
22
+ returnFocusRef?: RefObject<HTMLElement>;
23
+ };
24
+
25
+ export function MobileNavDrawer({ isOpen, onClose, returnFocusRef }: MobileNavDrawerProps) {
26
+ const { t } = useTranslation();
27
+ const menuItems = useMenuItemsPC();
28
+ const branding = useBranding();
29
+ const { organization } = useOrganization();
30
+ const displayName = branding?.brand_name || organization?.name || '';
31
+ const drawerRef = useRef<HTMLDivElement>(null);
32
+ const firstNavRef = useRef<HTMLDivElement>(null);
33
+
34
+ const flatNavItems = useMemo(
35
+ () => menuItems.filter((item) => Boolean(item.href)),
36
+ [menuItems],
37
+ );
38
+
39
+ // body scroll lock
40
+ useEffect(() => {
41
+ if (typeof document === 'undefined') return;
42
+ if (!isOpen) return;
43
+ const previous = document.body.style.overflow;
44
+ document.body.style.overflow = 'hidden';
45
+ return () => {
46
+ document.body.style.overflow = previous;
47
+ };
48
+ }, [isOpen]);
49
+
50
+ // Esc to close + focus management
51
+ useEffect(() => {
52
+ if (!isOpen || typeof window === 'undefined') return;
53
+
54
+ const onKey = (event: KeyboardEvent) => {
55
+ if (event.key === 'Escape') {
56
+ event.preventDefault();
57
+ onClose();
58
+ }
59
+ };
60
+ window.addEventListener('keydown', onKey);
61
+
62
+ // Move focus into drawer
63
+ const focusTimer = window.setTimeout(() => {
64
+ firstNavRef.current?.focus();
65
+ // If the wrapper isn't focusable, focus the first focusable child
66
+ const target = firstNavRef.current?.querySelector<HTMLElement>(
67
+ 'button, [href], [tabindex]:not([tabindex="-1"])',
68
+ );
69
+ target?.focus();
70
+ }, 30);
71
+
72
+ return () => {
73
+ window.removeEventListener('keydown', onKey);
74
+ window.clearTimeout(focusTimer);
75
+ };
76
+ }, [isOpen, onClose]);
77
+
78
+ // return focus to trigger on close
79
+ useEffect(() => {
80
+ if (isOpen) return;
81
+ if (typeof window === 'undefined') return;
82
+ const node = returnFocusRef?.current;
83
+ if (!node) return;
84
+ const t = window.setTimeout(() => node.focus(), 0);
85
+ return () => window.clearTimeout(t);
86
+ }, [isOpen, returnFocusRef]);
87
+
88
+ const handleNavigate = useCallback(() => {
89
+ onClose();
90
+ }, [onClose]);
91
+
92
+ const overlayStyle: CSSProperties = {
93
+ position: 'fixed',
94
+ inset: 0,
95
+ zIndex: 1190,
96
+ background: getVar('--pc-bg-overlay'),
97
+ opacity: isOpen ? 1 : 0,
98
+ pointerEvents: isOpen ? 'auto' : 'none',
99
+ transition: 'opacity 200ms ease',
100
+ };
101
+
102
+ const drawerStyle: CSSProperties = {
103
+ position: 'fixed',
104
+ top: 0,
105
+ left: 0,
106
+ bottom: 0,
107
+ width: 280,
108
+ maxWidth: '85vw',
109
+ zIndex: 1195,
110
+ background: getVar('--pc-bg-shell'),
111
+ borderRight: `1px solid ${getVar('--pc-border-color')}`,
112
+ boxShadow: '12px 0 32px rgba(15, 23, 42, 0.18)',
113
+ transform: isOpen ? 'translateX(0)' : 'translateX(-100%)',
114
+ transition: 'transform 220ms cubic-bezier(0.4, 0, 0.2, 1)',
115
+ display: 'flex',
116
+ flexDirection: 'column',
117
+ };
118
+
119
+ if (typeof document === 'undefined') return null;
120
+
121
+ return createPortal(
122
+ <>
123
+ <div
124
+ style={overlayStyle}
125
+ onClick={onClose}
126
+ aria-hidden="true"
127
+ />
128
+ <aside
129
+ ref={drawerRef}
130
+ role="dialog"
131
+ aria-modal="true"
132
+ aria-label={t('openSidebarMenu')}
133
+ aria-hidden={!isOpen}
134
+ style={drawerStyle}
135
+ >
136
+ <div
137
+ style={{
138
+ display: 'flex',
139
+ alignItems: 'center',
140
+ justifyContent: 'space-between',
141
+ gap: 12,
142
+ height: 56,
143
+ padding: '0 12px 0 16px',
144
+ borderBottom: `1px solid ${getVar('--pc-border-color')}`,
145
+ flexShrink: 0,
146
+ }}
147
+ >
148
+ <div
149
+ style={{
150
+ display: 'flex',
151
+ alignItems: 'center',
152
+ minWidth: 0,
153
+ flex: 1,
154
+ }}
155
+ >
156
+ {branding?.logo_url ? (
157
+ <img
158
+ src={branding.logo_url}
159
+ alt={displayName}
160
+ style={{
161
+ height: 36,
162
+ width: 'auto',
163
+ maxWidth: 160,
164
+ objectFit: 'contain',
165
+ display: 'block',
166
+ }}
167
+ />
168
+ ) : branding?.icon_url ? (
169
+ <PCAvatar src={branding.icon_url} name={displayName} size="md" color="info" />
170
+ ) : (
171
+ <PCAvatar name={displayName || '?'} size="md" color="info" />
172
+ )}
173
+ </div>
174
+ <PCActionIcon
175
+ variant="default"
176
+ aria-label={t('closeSidebarMenu')}
177
+ onClick={onClose}
178
+ style={{
179
+ width: 36,
180
+ height: 36,
181
+ padding: 0,
182
+ color: getVar('--pc-text-primary'),
183
+ flexShrink: 0,
184
+ }}
185
+ >
186
+ <X size={20} />
187
+ </PCActionIcon>
188
+ </div>
189
+ <nav
190
+ role="navigation"
191
+ aria-label="Privacy center primary navigation"
192
+ ref={firstNavRef}
193
+ tabIndex={-1}
194
+ style={{
195
+ flex: 1,
196
+ overflowY: 'auto',
197
+ overflowX: 'hidden',
198
+ padding: '12px 12px 24px',
199
+ display: 'flex',
200
+ flexDirection: 'column',
201
+ gap: 4,
202
+ outline: 'none',
203
+ }}
204
+ >
205
+ {flatNavItems.map((item) => (
206
+ <NavItem
207
+ key={item.href ?? item.name}
208
+ item={item}
209
+ mode="expanded"
210
+ onNavigate={handleNavigate}
211
+ />
212
+ ))}
213
+ </nav>
214
+ </aside>
215
+ </>,
216
+ document.body,
217
+ );
218
+ }
@@ -0,0 +1,205 @@
1
+ import { useState, type CSSProperties, type KeyboardEvent } from 'react';
2
+ import { PCTooltip } from '../../ui';
3
+ import { useInternalNavigation, type InternalPage } from '../../context/NavigationContext';
4
+ import { getVar } from '../../lib/utils';
5
+ import type { MenuItem } from '../SideMenuBarPC/types';
6
+
7
+ type NavItemProps = {
8
+ item: MenuItem;
9
+ mode: 'expanded' | 'collapsed';
10
+ onNavigate?: () => void;
11
+ };
12
+
13
+ export function NavItem({ item, mode, onNavigate }: NavItemProps) {
14
+ const { currentPage, navigateTo } = useInternalNavigation();
15
+ const [hover, setHover] = useState(false);
16
+
17
+ const isCollapsed = mode === 'collapsed';
18
+ const isActive = item.href ? currentPage === (item.href as InternalPage) : false;
19
+ const isDisabled = !!item.isComingSoon;
20
+ const accent = getVar('--pc-primary');
21
+
22
+ const handleActivate = () => {
23
+ if (isDisabled || !item.href) return;
24
+ navigateTo(item.href as InternalPage);
25
+ onNavigate?.();
26
+ };
27
+
28
+ const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
29
+ if (event.key === 'Enter' || event.key === ' ') {
30
+ event.preventDefault();
31
+ handleActivate();
32
+ }
33
+ };
34
+
35
+ const labelColor = isDisabled
36
+ ? getVar('--pc-text-disabled')
37
+ : isActive
38
+ ? accent
39
+ : getVar('--pc-text-primary');
40
+
41
+ // Icon container styling — distinct from row, gives each item a tactile feel.
42
+ const containerBg = isDisabled
43
+ ? 'transparent'
44
+ : isActive
45
+ ? getVar('--pc-bg-tertiary')
46
+ : hover
47
+ ? getVar('--pc-bg-tertiary')
48
+ : getVar('--pc-bg-secondary');
49
+
50
+ const containerColor = isDisabled
51
+ ? getVar('--pc-text-disabled')
52
+ : isActive
53
+ ? accent
54
+ : hover
55
+ ? getVar('--pc-text-primary')
56
+ : getVar('--pc-text-secondary');
57
+
58
+ const containerTransform =
59
+ !isDisabled && !isActive && hover ? 'translateX(1px)' : 'translateX(0)';
60
+
61
+ const buttonStyle: CSSProperties = isCollapsed
62
+ ? {
63
+ position: 'relative',
64
+ display: 'flex',
65
+ alignItems: 'center',
66
+ justifyContent: 'center',
67
+ width: 40,
68
+ height: 40,
69
+ padding: 0,
70
+ margin: 0,
71
+ background: 'transparent',
72
+ color: labelColor,
73
+ border: 'none',
74
+ cursor: isDisabled ? 'not-allowed' : 'pointer',
75
+ opacity: isDisabled ? 0.5 : 1,
76
+ outline: 'none',
77
+ borderRadius: 8,
78
+ transition: 'background-color 150ms ease',
79
+ fontFamily: 'inherit',
80
+ }
81
+ : {
82
+ position: 'relative',
83
+ display: 'flex',
84
+ alignItems: 'center',
85
+ width: '100%',
86
+ height: 44,
87
+ padding: '0 12px 0 14px',
88
+ gap: 10,
89
+ borderRadius: 10,
90
+ background: isActive ? getVar('--pc-bg-hover') : 'transparent',
91
+ color: labelColor,
92
+ border: 'none',
93
+ cursor: isDisabled ? 'not-allowed' : 'pointer',
94
+ opacity: isDisabled ? 0.5 : 1,
95
+ outline: 'none',
96
+ transition: 'background-color 150ms ease, color 150ms ease, box-shadow 150ms ease',
97
+ fontFamily: 'inherit',
98
+ fontSize: 14,
99
+ fontWeight: isActive ? 600 : 500,
100
+ lineHeight: 1,
101
+ textAlign: 'left',
102
+ };
103
+
104
+ const accentBar = isActive && !isCollapsed ? (
105
+ <span
106
+ aria-hidden="true"
107
+ style={{
108
+ position: 'absolute',
109
+ left: 0,
110
+ top: 8,
111
+ bottom: 8,
112
+ width: 4,
113
+ borderRadius: '0 4px 4px 0',
114
+ background: accent,
115
+ }}
116
+ />
117
+ ) : null;
118
+
119
+ const iconContainer = item.icon ? (
120
+ <span
121
+ className="pc-shell-nav-item-icon"
122
+ aria-hidden="true"
123
+ style={{
124
+ display: 'inline-flex',
125
+ alignItems: 'center',
126
+ justifyContent: 'center',
127
+ width: 32,
128
+ height: 32,
129
+ borderRadius: 8,
130
+ background: containerBg,
131
+ color: containerColor,
132
+ flexShrink: 0,
133
+ transition: 'background-color 150ms ease, color 150ms ease, transform 150ms ease',
134
+ transform: containerTransform,
135
+ }}
136
+ >
137
+ {item.icon}
138
+ </span>
139
+ ) : null;
140
+
141
+ const label = !isCollapsed ? (
142
+ <span
143
+ style={{
144
+ flex: 1,
145
+ minWidth: 0,
146
+ overflow: 'hidden',
147
+ textOverflow: 'ellipsis',
148
+ whiteSpace: 'nowrap',
149
+ }}
150
+ >
151
+ {item.name}
152
+ </span>
153
+ ) : null;
154
+
155
+ const badge = item.isNew && !isCollapsed && !isDisabled ? (
156
+ <span
157
+ style={{
158
+ display: 'inline-flex',
159
+ alignItems: 'center',
160
+ flexShrink: 0,
161
+ padding: '2px 8px',
162
+ borderRadius: 9999,
163
+ background: getVar('--pc-bg-hover'),
164
+ color: accent,
165
+ fontSize: 11,
166
+ fontWeight: 600,
167
+ lineHeight: 1.2,
168
+ }}
169
+ >
170
+ New
171
+ </span>
172
+ ) : null;
173
+
174
+ const button = (
175
+ <button
176
+ type="button"
177
+ className="pc-shell-nav-item"
178
+ role="link"
179
+ aria-current={isActive ? 'page' : undefined}
180
+ aria-disabled={isDisabled || undefined}
181
+ aria-label={isCollapsed ? item.name : undefined}
182
+ disabled={isDisabled}
183
+ onClick={handleActivate}
184
+ onKeyDown={handleKeyDown}
185
+ onMouseEnter={() => setHover(true)}
186
+ onMouseLeave={() => setHover(false)}
187
+ style={buttonStyle}
188
+ >
189
+ {accentBar}
190
+ {iconContainer}
191
+ {label}
192
+ {badge}
193
+ </button>
194
+ );
195
+
196
+ if (isCollapsed) {
197
+ return (
198
+ <PCTooltip content={item.name} placement="right" color="invert">
199
+ <div style={{ width: 40, height: 40, margin: '0 auto' }}>{button}</div>
200
+ </PCTooltip>
201
+ );
202
+ }
203
+
204
+ return button;
205
+ }