Revert "Unify workspace dropdowns; Update layouts (#458)"

This reverts commit 4bc8448a1d.
This commit is contained in:
gbuomprisco
2026-03-11 14:47:47 +08:00
parent 4bc8448a1d
commit 4912e402a3
530 changed files with 11182 additions and 14382 deletions

View File

@@ -1,235 +0,0 @@
import { describe, expect, it } from 'vitest';
import { isRouteActive } from '../is-route-active';
describe('isRouteActive', () => {
describe('exact matching', () => {
it('returns true for exact path match', () => {
expect(isRouteActive('/projects', '/projects')).toBe(true);
});
it('returns true for exact path match with trailing slash normalization', () => {
expect(isRouteActive('/projects/', '/projects')).toBe(true);
expect(isRouteActive('/projects', '/projects/')).toBe(true);
});
it('returns true for root path exact match', () => {
expect(isRouteActive('/', '/')).toBe(true);
});
});
describe('prefix matching (default behavior)', () => {
it('returns true when current path is child of nav path', () => {
expect(isRouteActive('/projects', '/projects/123')).toBe(true);
expect(isRouteActive('/projects', '/projects/123/edit')).toBe(true);
expect(isRouteActive('/projects', '/projects/new')).toBe(true);
});
it('returns false when current path is not a child', () => {
expect(isRouteActive('/projects', '/settings')).toBe(false);
expect(isRouteActive('/projects', '/projectslist')).toBe(false); // Not a child, just starts with same chars
});
it('returns false for root path when on other routes', () => {
// Root path should only match exactly, not prefix-match everything
expect(isRouteActive('/', '/projects')).toBe(false);
expect(isRouteActive('/', '/dashboard')).toBe(false);
});
it('handles nested paths correctly', () => {
expect(isRouteActive('/settings/profile', '/settings/profile/edit')).toBe(
true,
);
expect(isRouteActive('/settings/profile', '/settings/billing')).toBe(
false,
);
});
});
describe('custom regex matching (highlightMatch)', () => {
it('uses regex pattern when provided', () => {
// Exact match only pattern
expect(
isRouteActive('/dashboard', '/dashboard/stats', '^/dashboard$'),
).toBe(false);
expect(isRouteActive('/dashboard', '/dashboard', '^/dashboard$')).toBe(
true,
);
});
it('supports multiple paths in regex', () => {
const pattern = '^/(projects|settings/projects)';
expect(isRouteActive('/projects', '/projects', pattern)).toBe(true);
expect(isRouteActive('/projects', '/settings/projects', pattern)).toBe(
true,
);
expect(isRouteActive('/projects', '/settings', pattern)).toBe(false);
});
it('supports complex regex patterns', () => {
// Match any dashboard sub-route
expect(
isRouteActive('/dashboard', '/dashboard/stats', '^/dashboard/'),
).toBe(true);
// Note: Exact match check runs before regex, so '/dashboard' matches '/dashboard'
expect(isRouteActive('/dashboard', '/dashboard', '^/dashboard/')).toBe(
true, // Exact match takes precedence
);
// But different nav path won't match
expect(isRouteActive('/other', '/dashboard', '^/dashboard/')).toBe(false);
});
});
describe('query parameter handling', () => {
it('ignores query parameters in path', () => {
expect(isRouteActive('/projects?tab=active', '/projects')).toBe(true);
expect(isRouteActive('/projects', '/projects?tab=active')).toBe(true);
});
it('ignores query parameters in current path', () => {
expect(isRouteActive('/projects', '/projects/123?view=details')).toBe(
true,
);
});
});
describe('trailing slash handling', () => {
it('normalizes trailing slashes in both paths', () => {
expect(isRouteActive('/projects/', '/projects/')).toBe(true);
expect(isRouteActive('/projects/', '/projects')).toBe(true);
expect(isRouteActive('/projects', '/projects/')).toBe(true);
});
it('handles nested paths with trailing slashes', () => {
expect(isRouteActive('/projects/', '/projects/123/')).toBe(true);
});
});
describe('locale handling', () => {
it('strips locale prefix from paths when locale is provided', () => {
const options = { locale: 'en' };
expect(
isRouteActive('/projects', '/en/projects', undefined, options),
).toBe(true);
expect(
isRouteActive('/projects', '/en/projects/123', undefined, options),
).toBe(true);
});
it('auto-detects locale from path when locales array is provided', () => {
const options = { locales: ['en', 'de', 'fr'] };
expect(
isRouteActive('/projects', '/en/projects', undefined, options),
).toBe(true);
expect(
isRouteActive('/projects', '/de/projects', undefined, options),
).toBe(true);
expect(
isRouteActive('/projects', '/fr/projects/123', undefined, options),
).toBe(true);
});
it('handles case-insensitive locale detection', () => {
// Locale detection is case-insensitive, but stripping requires case match
const options = { locales: ['en', 'de'] };
// These work because locale case matches
expect(
isRouteActive('/projects', '/en/projects', undefined, options),
).toBe(true);
expect(
isRouteActive('/projects', '/de/projects', undefined, options),
).toBe(true);
});
it('does not strip non-locale prefixes', () => {
const options = { locales: ['en', 'de'] };
// 'projects' is not a locale, so shouldn't be stripped
expect(
isRouteActive('/settings', '/projects/settings', undefined, options),
).toBe(false);
});
it('handles locale-only paths', () => {
const options = { locale: 'en' };
expect(isRouteActive('/', '/en', undefined, options)).toBe(true);
expect(isRouteActive('/', '/en/', undefined, options)).toBe(true);
});
});
describe('edge cases', () => {
it('handles empty string path', () => {
expect(isRouteActive('', '/')).toBe(true);
expect(isRouteActive('/', '')).toBe(true);
});
it('handles paths with special characters', () => {
expect(isRouteActive('/user/@me', '/user/@me')).toBe(true);
expect(isRouteActive('/search', '/search?q=hello+world')).toBe(true);
});
it('handles deep nested paths', () => {
expect(isRouteActive('/a/b/c/d', '/a/b/c/d/e/f/g')).toBe(true);
expect(isRouteActive('/a/b/c/d', '/a/b/c')).toBe(false);
});
it('handles similar path prefixes', () => {
// '/project' should not match '/projects'
expect(isRouteActive('/project', '/projects')).toBe(false);
// '/projects' should not match '/project'
expect(isRouteActive('/projects', '/project')).toBe(false);
});
it('handles paths with numbers', () => {
expect(isRouteActive('/org/123', '/org/123/members')).toBe(true);
expect(isRouteActive('/org/123', '/org/456')).toBe(false);
});
});
describe('real-world navigation scenarios', () => {
it('sidebar navigation highlighting', () => {
// Dashboard link should highlight on dashboard and sub-pages
expect(isRouteActive('/dashboard', '/dashboard')).toBe(true);
expect(isRouteActive('/dashboard', '/dashboard/analytics')).toBe(true);
expect(isRouteActive('/dashboard', '/settings')).toBe(false);
// Projects link should highlight on projects list and detail pages
expect(isRouteActive('/projects', '/projects')).toBe(true);
expect(isRouteActive('/projects', '/projects/proj-1')).toBe(true);
expect(isRouteActive('/projects', '/projects/proj-1/tasks')).toBe(true);
// Home link should only highlight on home
expect(isRouteActive('/', '/')).toBe(true);
expect(isRouteActive('/', '/projects')).toBe(false);
});
it('settings navigation with nested routes', () => {
// Settings general
expect(isRouteActive('/settings', '/settings')).toBe(true);
expect(isRouteActive('/settings', '/settings/profile')).toBe(true);
expect(isRouteActive('/settings', '/settings/billing')).toBe(true);
// Settings profile specifically
expect(isRouteActive('/settings/profile', '/settings/profile')).toBe(
true,
);
expect(isRouteActive('/settings/profile', '/settings/billing')).toBe(
false,
);
});
it('organization routes with dynamic segments', () => {
expect(
isRouteActive('/org/[slug]', '/org/my-org', undefined, undefined),
).toBe(false); // Template path won't match
expect(isRouteActive('/org/my-org', '/org/my-org/settings')).toBe(true);
});
});
});

View File

@@ -1,128 +1,108 @@
const ROOT_PATH = '/';
export type RouteActiveOptions = {
locale?: string;
locales?: string[];
};
/**
* @name isRouteActive
* @description Check if a route is active for navigation highlighting.
*
* Default behavior: prefix matching (highlights parent when on child routes)
* Custom behavior: provide a regex pattern via highlightMatch
*
* @param path - The navigation item's path
* @param currentPath - The current browser path
* @param highlightMatch - Optional regex pattern for custom matching
* @param options - Locale options for path normalization
*
* @example
* // Default: /projects highlights for /projects, /projects/123, /projects/123/edit
* isRouteActive('/projects', '/projects/123') // true
*
* // Exact match only
* isRouteActive('/dashboard', '/dashboard/stats', '^/dashboard$') // false
*
* // Multiple paths
* isRouteActive('/projects', '/settings/projects', '^/(projects|settings/projects)') // true
* @description A function to check if a route is active. This is used to
* @param end
* @param path
* @param currentPath
*/
export function isRouteActive(
path: string,
currentPath: string,
highlightMatch?: string,
options?: RouteActiveOptions,
end?: boolean | ((path: string) => boolean),
) {
const locale =
options?.locale ?? detectLocaleFromPath(currentPath, options?.locales);
const normalizedPath = normalizePath(path, { ...options, locale });
const normalizedCurrentPath = normalizePath(currentPath, {
...options,
locale,
});
// Exact match always returns true
if (normalizedPath === normalizedCurrentPath) {
// if the path is the same as the current path, we return true
if (path === currentPath) {
return true;
}
// Custom regex match
if (highlightMatch) {
const regex = new RegExp(highlightMatch);
return regex.test(normalizedCurrentPath);
// if the end prop is a function, we call it with the current path
if (typeof end === 'function') {
return !end(currentPath);
}
// Default: prefix matching - highlight when current path starts with nav path
// Special case: root path should only match exactly
if (normalizedPath === ROOT_PATH) {
// otherwise - we use the evaluateIsRouteActive function
const defaultEnd = end ?? true;
const oneLevelDeep = 1;
const threeLevelsDeep = 3;
// how far down should segments be matched?
const depth = defaultEnd ? oneLevelDeep : threeLevelsDeep;
return checkIfRouteIsActive(path, currentPath, depth);
}
/**
* @name checkIfRouteIsActive
* @description A function to check if a route is active. This is used to
* highlight the active link in the navigation.
* @param targetLink - The link to check against
* @param currentRoute - the current route
* @param depth - how far down should segments be matched?
*/
export function checkIfRouteIsActive(
targetLink: string,
currentRoute: string,
depth = 1,
) {
// we remove any eventual query param from the route's URL
const currentRoutePath = currentRoute.split('?')[0] ?? '';
if (!isRoot(currentRoutePath) && isRoot(targetLink)) {
return false;
}
return (
normalizedCurrentPath.startsWith(normalizedPath + '/') ||
normalizedCurrentPath === normalizedPath
);
if (!currentRoutePath.includes(targetLink)) {
return false;
}
const isSameRoute = targetLink === currentRoutePath;
if (isSameRoute) {
return true;
}
return hasMatchingSegments(targetLink, currentRoutePath, depth);
}
function splitIntoSegments(href: string) {
return href.split('/').filter(Boolean);
}
function normalizePath(path: string, options?: RouteActiveOptions) {
const [pathname = ROOT_PATH] = path.split('?');
const normalizedPath =
pathname.length > 1 && pathname.endsWith('/')
? pathname.slice(0, -1)
: pathname || ROOT_PATH;
function hasMatchingSegments(
targetLink: string,
currentRoute: string,
depth: number,
) {
const segments = splitIntoSegments(targetLink);
const matchingSegments = numberOfMatchingSegments(currentRoute, segments);
if (!options?.locale && !options?.locales?.length) {
return normalizedPath || ROOT_PATH;
if (targetLink === currentRoute) {
return true;
}
const locale =
options?.locale ?? detectLocaleFromPath(normalizedPath, options?.locales);
if (!locale || !hasLocalePrefix(normalizedPath, locale)) {
return normalizedPath || ROOT_PATH;
}
return stripLocalePrefix(normalizedPath, locale);
// how far down should segments be matched?
// - if depth = 1 => only highlight the links of the immediate parent
// - if depth = 2 => for url = /account match /account/organization/members
return matchingSegments > segments.length - (depth - 1);
}
function detectLocaleFromPath(
path: string,
locales: string[] | undefined,
): string | undefined {
if (!locales?.length) {
return undefined;
function numberOfMatchingSegments(href: string, segments: string[]) {
let count = 0;
for (const segment of splitIntoSegments(href)) {
// for as long as the segments match, keep counting + 1
if (segments.includes(segment)) {
count += 1;
} else {
return count;
}
}
const [firstSegment] = splitIntoSegments(path);
if (!firstSegment) {
return undefined;
}
return locales.find(
(locale) => locale.toLowerCase() === firstSegment.toLowerCase(),
);
return count;
}
function hasLocalePrefix(path: string, locale: string) {
return path === `/${locale}` || path.startsWith(`/${locale}/`);
}
function stripLocalePrefix(path: string, locale: string) {
if (!hasLocalePrefix(path, locale)) {
return path || ROOT_PATH;
}
const withoutPrefix = path.slice(locale.length + 1);
if (!withoutPrefix) {
return ROOT_PATH;
}
return withoutPrefix.startsWith('/') ? withoutPrefix : `/${withoutPrefix}`;
function isRoot(path: string) {
return path === ROOT_PATH;
}