Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 98 additions & 78 deletions src/lib/Link/Link.svelte
Original file line number Diff line number Diff line change
@@ -1,108 +1,128 @@
<script lang="ts">
import { location } from '$lib/core/Location.js';
import { joinPaths, resolveHashValue } from '$lib/core/RouterEngine.svelte.js';
import { getLinkContext, type ILinkContext } from '$lib/LinkContext/LinkContext.svelte';
import { getRouterContext } from '$lib/Router/Router.svelte';
import type { ActiveState } from '$lib/types.js';
import { type Snippet } from 'svelte';
import type { HTMLAnchorAttributes } from 'svelte/elements';

type Props = HTMLAnchorAttributes & {
/**
* Sets the hash mode of the component.
*
* If `true`, the component will search for the immediate parent router configured for single hash routing.
*
* If a string, the component will search for the immediate parent router configured for multi hash routing
* that matches the string.
*
* If `false`, the component will search for the immediate parent router configured for path routing.
*
* If left undefined, it will resolve to one of the previous values based on the `implicitMode` routing option.
*
* **IMPORTANT**: Because the hash value directly affects the search for the parent router, it cannot be
* reactively set to different values at will. If you must do this, destroy and recreate the component
* whenever the hash changes:
*
* @example
* ```svelte
* {#key hash}
* <Link {hash} />
* {/key}
* ```
*
* Unlike other components, the `Link` component does not need a parent router to function. It can be used
* anywhere in the application. The following features, however, will not work:
*
* - The `activeState` property: It depends on the parent router to set its active state reactively when a
* route becomes active.
* - The `prependBasePath` property: It depends on the parent router to set the base path for the link.
*/
hash?: boolean | string;
/**
* Sets the URL to navigate to. Never use a full URL; always use relative or absolute paths.
*/
href: string;
/**
* Configures the link so it replaces the current URL as opposed to pushing the URL as a new entry in the
* browser's History API.
*/
replace?: boolean;
/**
* Sets the state object to pass to the browser's History API when pushing or replacing the URL.
*
* If a function is provided, it will be called and its return value will be used as the state object.
*/
state?: any;
/**
* Sets the various options that are used to automatically style the anchor tag whenever a particular route
* becomes active.
*
* **IMPORTANT**: This only works if the component is within a `Router` component.
*/
activeState?: ActiveState;
/**
* Configures the component to prepend the parent router's base path to the `href` property.
*
* This is recommended to achieve path independence in the application. If, say, a NavBar component contains
* links to different parts of the application, and this is a reusable component or a micro-frontend, then the
* Link components inside the NavBar will inherit whatever route the controller shell has assigned to the
* instance of the NavBar.
*
* **IMPORTANT**: This only works if the component is within a `Router` component.
*/
prependBasePath?: boolean;
/**
* Renders the children of the component.
*/
children?: Snippet;
};
type Props = HTMLAnchorAttributes &
ILinkContext & {
/**
* Sets the hash mode of the component.
*
* If `true`, the component will search for the immediate parent router configured for single hash routing.
*
* If a string, the component will search for the immediate parent router configured for multi hash routing
* that matches the string.
*
* If `false`, the component will search for the immediate parent router configured for path routing.
*
* If left undefined, it will resolve to one of the previous values based on the `implicitMode` routing option.
*
* **IMPORTANT**: Because the hash value directly affects the search for the parent router, it cannot be
* reactively set to different values at will. If you must do this, destroy and recreate the component
* whenever the hash changes:
*
* @example
* ```svelte
* {#key hash}
* <Link {hash} />
* {/key}
* ```
*
* Unlike other components, the `Link` component does not need a parent router to function. It can be used
* anywhere in the application. The following features, however, will not work:
*
* - The `activeState` property: It depends on the parent router to set its active state reactively when a
* route becomes active.
* - The `prependBasePath` property: It depends on the parent router to set the base path for the link.
*/
hash?: boolean | string;
/**
* Sets the URL to navigate to. Never use a full URL; always use relative or absolute paths.
*/
href: string;
/**
* Sets the state object to pass to the browser's History API when pushing or replacing the URL.
*
* If a function is provided, it will be called and its return value will be used as the state object.
*/
state?: any;
/**
* Sets the various options that are used to automatically style the anchor tag whenever a particular route
* becomes active.
*
* **IMPORTANT**: This only works if the component is within a `Router` component.
*/
activeState?: ActiveState;
/**
* Renders the children of the component.
*/
children?: Snippet;
};

let {
hash,
href,
replace = false,
replace,
state,
activeState,
class: cssClass,
style,
prependBasePath = false,
prependBasePath,
preserveQuery,
children,
...restProps
}: Props = $props();

const router = getRouterContext(resolveHashValue(hash));
const linkContext = getLinkContext();

let isActive = $derived(!!router?.routeStatus[activeState?.key ?? '']?.match);
let calcHref = $derived(prependBasePath ? joinPaths(router?.basePath ?? '', href) : href);
const calcReplace = $derived(replace ?? linkContext?.replace ?? false);
const calcPreserveQuery = $derived(preserveQuery ?? linkContext?.preserveQuery ?? false);
const calcPrependBasePath = $derived(prependBasePath ?? linkContext?.prependBasePath ?? false);
const isActive = $derived(!!router?.routeStatus[activeState?.key ?? '']?.match);
const calcHref = $derived(buildHref());

function buildHref() {
const pathname = calcPrependBasePath ? joinPaths(router?.basePath ?? '', href) : href;
if (hash || !calcPreserveQuery || !location.url.searchParams.size) {
return pathname;
}
let searchParams: URLSearchParams;
if (calcPreserveQuery === true) {
searchParams = location.url.searchParams;
} else {
searchParams = new URLSearchParams();
const transferValue = (key: string) => {
const value = location.url.searchParams.getAll(key);
if (value) {
value.forEach((v) => searchParams.append(key, v));
}
};
if (Array.isArray(calcPreserveQuery)) {
for (let key of calcPreserveQuery) {
transferValue(key);
}
} else {
transferValue(calcPreserveQuery);
}
}
return `${pathname}?${searchParams}`;
}

function handleClick(event: MouseEvent) {
event.preventDefault();
const newState = typeof state === 'function' ? state() : state;
if (typeof hash === 'string') {
location.navigate(calcHref, hash, { replace, state: newState });
}
else {
location.navigate(!!hash ? `#${calcHref}` : calcHref, { replace, state: newState });
location.navigate(calcHref, hash, { replace: calcReplace, state: newState });
} else {
location.navigate(!!hash ? `#${calcHref}` : calcHref, {
replace: calcReplace,
state: newState
});
}
}

Expand Down
1 change: 1 addition & 0 deletions src/lib/Link/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ SPA-friendly navigation (navigation without reloading).
| `state` | `any` | `undefined` | | Sets the state object to pass to the browser's History API when pushing or replacing the URL. |
| `activeState` | `ActiveState` | `undefined` | | Sets the various options that are used to automatically style the anchor tag whenever a particular route becomes active. |
| `prependBasePath` | `boolean` | `false` | | Configures the component to prepend the parent router's base path to the `href` property. |
| `preserveQuery` | `boolean \| string \| string[]` | `false` | | Configures the component to preserve the query string whenever it triggers navigation. |
| `children` | `Snippet` | `undefined` | | Renders the children of the component. |

[Online Documentation](https://wjfe-n-savant.hashnode.space/wjfe-n-savant/components/link)
Expand Down
91 changes: 91 additions & 0 deletions src/lib/LinkContext/LinkContext.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<script lang="ts" module>
import { getContext, setContext, type Snippet } from 'svelte';

export type ILinkContext = {
/**
* Configures the link so it replaces the current URL as opposed to pushing the URL as a new entry in the
* browser's History API.
*/
replace?: boolean;
/**
* Configures the component to prepend the parent router's base path to the `href` property.
*
* This is recommended to achieve path independence in the application. If, say, a NavBar component contains
* links to different parts of the application, and this is a reusable component or a micro-frontend, then the
* Link components inside the NavBar will inherit whatever route the controller shell has assigned to the
* instance of the NavBar.
*
* **IMPORTANT**: This only works if the component is within a `Router` component.
*/
prependBasePath?: boolean;
/**
* Configures the component to preserve the query string whenever it triggers navigation.
*
* This is useful when you have query string values that are controlled by components that are not changing
* with the route. For example, a search component that is not part of the route but is used to filter the
* data displayed on the page.
*
* Set to `false` to not preserve the query string.
*
* Set to `true` to preserve the entire query string.
*
* Set to a string or an array of strings to preserve only the specified query string values.
*/
preserveQuery?: boolean | string | string[];
};

class _LinkContext implements ILinkContext {
replace = $state(false);
prependBasePath = $state(false);
preserveQuery = $state<ILinkContext['preserveQuery']>(false);

constructor(
replace: boolean,
prependBasePath: boolean,
preserveQuery: ILinkContext['preserveQuery']
) {
this.replace = replace;
this.prependBasePath = prependBasePath;
this.preserveQuery = preserveQuery;
}
}

const linkCtxKey = Symbol();

export function getLinkContext() {
return getContext<ILinkContext | undefined>(linkCtxKey);
}
</script>

<script lang="ts">
type Props = ILinkContext & {
/**
* Renders the children of the component.
*/
children?: Snippet;
};

let {
replace = false,
prependBasePath = false,
preserveQuery = false,
children,
}: Props = $props();

const parentContext = getLinkContext();
const context = new _LinkContext(
parentContext?.replace ?? replace,
parentContext?.prependBasePath ?? prependBasePath,
parentContext?.preserveQuery ?? preserveQuery
);

setContext(linkCtxKey, context);

$effect.pre(() => {
context.prependBasePath = prependBasePath;
context.replace = replace;
context.preserveQuery = preserveQuery;
});
</script>

{@render children?.()}
45 changes: 45 additions & 0 deletions src/lib/LinkContext/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# LinkContext

The `LinkContext` component is used to create context for `Link` components. This context can be used to set, in
mass, the `replace`, `prependBasePath` and `preserveQuery` properties.

Instead of writing this:

```svelte
<Link prependBasePath preserveQuery href="...">...</Link>
<Link prependBasePath preserveQuery href="...">...</Link>
<Link prependBasePath preserveQuery href="...">...</Link>
<Link prependBasePath preserveQuery href="...">...</Link>
```

You can do:

```svelte
<LinkContext prependBasePath preserveQuery>
<Link href="...">...</Link>
<Link href="...">...</Link>
<Link href="...">...</Link>
</LinkContext>
```

Unlike the rest of components in this library, this one does not support the `hash` property. The context is
inherited by all links among its children.

**Note**: The `preserveQuery` option only has an effect on path routing links since hash routing links should not
lose the query string.

## Priorities

The `Link` component will give priority to an explicitly-set value at its property level. If a property-level value is
not found, then the context-provided property value is used. If there is no context, then the default value takes over.

## Props

| Property | Type | Default Value | Bindable | Description |
|-|-|-|-|-|
| `replace` | `boolean` | `false` | | Configures the link so it replaces the current URL as opposed to pushing the URL as a new entry in the browser's History API. |
| `prependBasePath` | `boolean` | `false` | | Configures the component to prepend the parent router's base path to the `href` property. |
| `preserveQuery` | `boolean \| string \| string[]` | `false` | | Configures the component to preserve the query string whenever it triggers navigation. |
| `children` | `Snippet` | `undefined` | | Renders the children of the component. |

[Online Documentation](https://wjfe-n-savant.hashnode.space/wjfe-n-savant/components/linkcontext)
1 change: 1 addition & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function init(options?: InitOptions): () => void {

export * from "$lib/Link/Link.svelte";
export { default as Link } from "$lib/Link/Link.svelte";
export { default as LinkContext } from "$lib/LinkContext/LinkContext.svelte";
export * from "$lib/Route/Route.svelte";
export { default as Route } from "$lib/Route/Route.svelte";
export * from "$lib/Router/Router.svelte";
Expand Down