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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [v1.0.2] - 2025-01-25

### Added
- Add `SearchParamChangeEvent` class extending `CustomEvent`
- Add `multiple` support for `SearchParam`
- `manageSearch` now uses `Proxy` to expose underlying properties and methods of values

### Changed
- Use calceleable `beforechange` event followed by a `change` event after

## [v1.0.1] - 2024-11-25

### Added
Expand Down
41 changes: 34 additions & 7 deletions SearchParam.js
Original file line number Diff line number Diff line change
@@ -1,36 +1,63 @@
const valueSymbol = Symbol('param:value');
const nameSymbol = Symbol('param:name');

/**
* Class representing a URL search parameter accessor.
* Extends `EventTarget` to support listening for updates on the parameter.
*/
export class SearchParam extends EventTarget {
#name;
#multiple = false;
#fallbackValue = '';

/**
* Creates a search parameter accessor.
* @param {string} name - The name of the URL search parameter to manage.
* @param {string|number} fallbackValue - The default value if the search parameter is not set.
* @param {string|number|Array} fallbackValue - The default value if the search parameter is not set. An array if `multiple` is true.
*/
constructor(name, fallbackValue) {
constructor(name, fallbackValue, { multiple = false } = {}) {
super();
this.#name = name;
this.#fallbackValue = fallbackValue;
this.#fallbackValue = multiple && ! Array.isArray(fallbackValue) ? Object.freeze([fallbackValue]) : Object.freeze(fallbackValue);
this.#multiple = multiple === true;
}

toString() {
return this.#value;
return this[SearchParam.valueSymbol];
}

[Symbol.iterator]() {
return this.#multiple ? this[valueSymbol] : [this[valueSymbol]];
}

get [Symbol.toStringTag]() {
return 'SearchParam';
}

[Symbol.toPrimitive](hint = 'default') {
return hint === 'number' ? parseFloat(this.#value) : this.#value;
return hint === 'number' ? parseFloat(this[valueSymbol]) : this[valueSymbol];
}

get #value() {
get [valueSymbol]() {
const params = new URLSearchParams(globalThis?.location.search);
return params.get(this.#name) ?? this.#fallbackValue?.toString() ?? '';

if (this.#multiple) {
const values = Object.freeze(params.getAll(this.#name));
return values.length === 0 ? this.#fallbackValue : values;
} else {
return params.get(this.#name) ?? this.#fallbackValue?.toString() ?? '';
}
}

get [nameSymbol]() {
return this.#name;
}

static get nameSymbol() {
return nameSymbol;
}

static get valueSymbol() {
return valueSymbol;
}
}
1 change: 1 addition & 0 deletions event.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export class SearchParamChangeEvent extends CustomEvent {}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@aegisjsproject/url",
"version": "1.0.1",
"version": "1.0.2",
"description": "Safe URL parsing/escaping via JS tagged templates",
"keywords": [
"aegis",
Expand Down
115 changes: 92 additions & 23 deletions search.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { SearchParam } from './SearchParam.js';
import { SearchParamChangeEvent } from './event.js';

/**
* Factory function for `SearchParam`
Expand All @@ -12,51 +13,119 @@ import { SearchParam } from './SearchParam.js';
* @param {boolean} [once=false] A boolean value that, if true, indicates that the function specified by listener will never call `preventDefault()`.
* @returns {SearchParam} An instance of a `SearchParam` object, dispatching a `change` event when changed
*/
export function getSearch(key, fallbackValue, onChange, { signal, passive = false, once = false } = {}) {
export function getSearch(key, fallbackValue, {
onChange,
onBeforeChange,
multiple = false,
signal,
passive = false,
once = false,
} = {}) {
const param = new SearchParam(key, fallbackValue, { multiple });

if (onChange instanceof Function) {
const param = new SearchParam(key, fallbackValue);
param.addEventListener('change', onChange, { signal, passive, once });
return param;
} else {
return new SearchParam(key, fallbackValue);
}

if (onBeforeChange instanceof Function) {
param.addEventListener('beforechange', onBeforeChange, { signal, passive, once });
}
return param;
}

/**
* Manages a specified URL search parameter as a live-updating stateful value.
* Manages search parameters in the URL with custom behavior for changes and updates.
*
* @param {string} key - The name of the URL search parameter to manage.
* @param {string|number} [fallbackValue=''] - The initial/fallback value if the search parameter is not set.
* @returns {[SearchParam, function(string|number): void]} - Returns a two-element array:
* - Returns a two-element array:
* - The first element is an object with:
* - A `toString` method, returning the current value of the URL parameter as a string.
* - A `[Symbol.toPrimitive]` method, allowing automatic conversion of the value based on the context (e.g., string or number).
* - The second element is a setter function that updates the URL search parameter to a new value, reflected immediately in the URL without reloading the page.
* @param {string} key - The name of the search parameter to manage.
* @param {string|Array} [fallbackValue=''] - The default value to use if the parameter is not present.
* @param {Object} [options={}] - Optional configuration for managing the search parameter.
* @param {function(SearchParamChangeEvent)} [options.onChange] - Callback invoked when the search parameter value changes.
* @param {function(SearchParamChangeEvent)} [options.onBeforeChange] - Callback invoked before the search parameter value changes.
* @param {boolean} [options.multiple=false] - Whether the parameter supports multiple values.
* @param {AbortSignal} [options.signal] - An AbortSignal to cancel ongoing operations.
* @param {boolean} [options.passive] - If true, changes to the parameter are not actively managed.
* @param {boolean} [options.once] - If true, the parameter management is only performed once.
* @returns {[Proxy<string | string[]>, function(newValue: *, options?: { method?: 'replace' | 'push', cause?: * }): void]} - An array containing two elements:
* - A `Proxy` object that interacts with the search parameter's value.
* - A function to update the search parameter, accepting a new value and options.
* - @param {*} newValue - The new value to set for the search parameter.
* - @param {Object} [updateOptions={}] - Options for the update operation.
* - @param {'replace'|'push'} [updateOptions.method='replace'] - How to update the browser history.
* - @param {*} [updateOptions.cause=null] - Additional context or reason for the update.
* @throws {TypeError} - Throws if an invalid update method is provided.
*/
export function manageSearch(key, fallbackValue = '', onChange, { signal, passive, once } = {}) {
const param = getSearch(key, fallbackValue, onChange, { once, passive, signal });
export function manageSearch(key, fallbackValue = '', {
onChange,
onBeforeChange,
multiple = false,
signal,
passive,
once,
} = {}) {
const param = getSearch(key, fallbackValue, { onChange, onBeforeChange, multiple, once, passive, signal });

return [
param,
new Proxy(param, {
get(target, prop) {
if (prop === 'addEventListener') {
return target.addEventListener.bind(target);
} else {
const val = target[SearchParam.valueSymbol];

if (typeof val === 'string') {
return val[prop] instanceof Function ? val[prop].bind(val) : val[prop];
} else {
return Reflect.get(target[SearchParam.valueSymbol], prop, target[SearchParam.valueSymbol]);
}
}
},
has(target, prop) {
return Reflect.has(target[SearchParam.valueSymbol], prop);
},
ownKeys(target) {
return Reflect.ownKeys(target[SearchParam.valueSymbol]);
},
isExtensible(target) {
return Reflect.isExtensible(target);
},
preventExtensions(target) {
return Reflect.preventExtensions(target);
},
getOwnPropertyDescriptor(target, prop) {
return Reflect.getOwnPropertyDescriptor(target[SearchParam.valueSymbol], prop);
}
}),
(newValue, { method = 'replace', cause = null } = {}) => {
const url = new URL(globalThis?.location?.href);
const oldValue = url.searchParams.get(key);
url.searchParams.set(key, newValue);
const oldValue = url.searchParams.get(key) ?? fallbackValue;

const event = new CustomEvent('change', {
cancelable: true,
detail: { name: key, newValue, oldValue, method, url, cause },
});
if (multiple && typeof newValue === 'object' && newValue[Symbol.iterator] instanceof Function) {
url.searchParams.delete(key);

for (const val of newValue) {
url.searchParams.append(key, val);
}
} else if (typeof newValue === 'undefined') {
url.searchParams.delete(key);
} else {
url.searchParams.set(key, newValue);
}

const detail = Object.freeze({ name: key, newValue, oldValue, method, url, cause });
const event = new SearchParamChangeEvent('beforechange', { cancelable: true, detail });

param.dispatchEvent(event);

if (event.defaultPrevented) {
return;
} else if (method === 'replace') {
history.replaceState(history.state, '', url.href);

param.dispatchEvent(new SearchParamChangeEvent('change', { cancelable: false, detail }));
} else if (method === 'push') {
history.pushState(history.state, '', url.href);

param.dispatchEvent(new SearchParamChangeEvent('change', { cancelable: false, detail }));
} else {
throw new TypeError(`Invalid update method: ${method}.`);
}
Expand Down
67 changes: 67 additions & 0 deletions search.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, test } from 'node:test';
import { ok, strictEqual, deepStrictEqual, throws, doesNotReject, rejects } from 'node:assert';
import { manageSearch } from './search.js';

describe('Test `manageSearch()` functionality', () => {
// Need to polyfill parts of `location` and `history` APIs for node.
globalThis.location = new URL(import.meta.url);
globalThis.history = {
state: null,
replaceState(state, unused, url) {
this.state = state;
globalThis.location = new URL(url, location);
}
};

test('Test basic functionality', async () => {
const [param, setParam] = manageSearch('test');
const signal = AbortSignal.timeout(1);
ok(param.addEventListener instanceof Function, 'Should support event listeners.');

const promise = new Promise((resolve, reject) => {
signal.addEventListener('abort', ({ target }) => reject(target.reason), { once: true });
param.addEventListener('change', resolve, { signal, once: true });
});

doesNotReject(() => promise, 'Events should dispatch.');
setParam('works');
setParam(undefined);
});

test('Assure rejections work', async () => {
const [param] = manageSearch('test');
const signal = AbortSignal.timeout(1);

const promise = new Promise((resolve, reject) => {
signal.addEventListener('abort', ({ target }) => reject(target.reason), { once: true });
param.addEventListener('change', resolve, { signal });
});

rejects(() => promise, 'Events should dispatch.');
});

test('Test single string params', () => {
const [name, setName] = manageSearch('name', '');
strictEqual(name.length, 0, 'Should proxy to underlying string length.');
setName('Fred');
ok(name.substring instanceof Function, 'Methods of params should be proxied to values.');
strictEqual(name.substring(0), 'Fred', 'Updating param should update the value.');
strictEqual(name.length, 4, 'Updating param should update the value length.');
strictEqual(location.search, '?name=Fred', 'Should update location correctly.');
setName(undefined);
strictEqual(location.search.length, 0, 'Setting no/empty values should remove the search param.');
});

test('Test arrays and multiple values.', () => {
const [list, setList] = manageSearch('list', [], { multiple: true });
strictEqual(list.length, 0, 'Should start off with a length of 0');
setList(['one', 'two', 'three']);
deepStrictEqual(list.map(item => item.toUpperCase()), ['ONE', 'TWO', 'THREE'], 'Should expose underlying methods of array.');
setList([...list, 'four']);
throws(() => list.push('five'), { name: 'TypeError' }, 'Params should be immutable.');
strictEqual(list.length, 4, 'Should implement the iterator protocol and spread syntax, updating values.');
strictEqual(location.search, '?list=one&list=two&list=three&list=four', 'Should update the `list` search param with multiple values.');
setList([]);
strictEqual(location.search.length, 0, 'Setting no/empty values should remove the search param.');
});
});
1 change: 1 addition & 0 deletions url.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { url, createURLParser } from './parser.js';
export { SearchParam } from './SearchParam.js';
export { getSearch, manageSearch } from './search.js';
export { SearchParamChangeEvent } from './event.js';