Revert "Unify workspace dropdowns; Update layouts (#458)"
This reverts commit 4bc8448a1d.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user