Skip to content

Commit a83daa2

Browse files
authored
Merge pull request #24 from AegisJSProject/patch/updates
Enhance `SearchParam`
2 parents c5178a2 + 8e64f20 commit a83daa2

8 files changed

Lines changed: 208 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [v1.0.2] - 2025-01-25
11+
12+
### Added
13+
- Add `SearchParamChangeEvent` class extending `CustomEvent`
14+
- Add `multiple` support for `SearchParam`
15+
- `manageSearch` now uses `Proxy` to expose underlying properties and methods of values
16+
17+
### Changed
18+
- Use calceleable `beforechange` event followed by a `change` event after
19+
1020
## [v1.0.1] - 2024-11-25
1121

1222
### Added

SearchParam.js

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,63 @@
1+
const valueSymbol = Symbol('param:value');
2+
const nameSymbol = Symbol('param:name');
3+
14
/**
25
* Class representing a URL search parameter accessor.
36
* Extends `EventTarget` to support listening for updates on the parameter.
47
*/
58
export class SearchParam extends EventTarget {
69
#name;
10+
#multiple = false;
711
#fallbackValue = '';
812

913
/**
1014
* Creates a search parameter accessor.
1115
* @param {string} name - The name of the URL search parameter to manage.
12-
* @param {string|number} fallbackValue - The default value if the search parameter is not set.
16+
* @param {string|number|Array} fallbackValue - The default value if the search parameter is not set. An array if `multiple` is true.
1317
*/
14-
constructor(name, fallbackValue) {
18+
constructor(name, fallbackValue, { multiple = false } = {}) {
1519
super();
1620
this.#name = name;
17-
this.#fallbackValue = fallbackValue;
21+
this.#fallbackValue = multiple && ! Array.isArray(fallbackValue) ? Object.freeze([fallbackValue]) : Object.freeze(fallbackValue);
22+
this.#multiple = multiple === true;
1823
}
1924

2025
toString() {
21-
return this.#value;
26+
return this[SearchParam.valueSymbol];
27+
}
28+
29+
[Symbol.iterator]() {
30+
return this.#multiple ? this[valueSymbol] : [this[valueSymbol]];
2231
}
2332

2433
get [Symbol.toStringTag]() {
2534
return 'SearchParam';
2635
}
2736

2837
[Symbol.toPrimitive](hint = 'default') {
29-
return hint === 'number' ? parseFloat(this.#value) : this.#value;
38+
return hint === 'number' ? parseFloat(this[valueSymbol]) : this[valueSymbol];
3039
}
3140

32-
get #value() {
41+
get [valueSymbol]() {
3342
const params = new URLSearchParams(globalThis?.location.search);
34-
return params.get(this.#name) ?? this.#fallbackValue?.toString() ?? '';
43+
44+
if (this.#multiple) {
45+
const values = Object.freeze(params.getAll(this.#name));
46+
return values.length === 0 ? this.#fallbackValue : values;
47+
} else {
48+
return params.get(this.#name) ?? this.#fallbackValue?.toString() ?? '';
49+
}
50+
}
51+
52+
get [nameSymbol]() {
53+
return this.#name;
54+
}
55+
56+
static get nameSymbol() {
57+
return nameSymbol;
58+
}
59+
60+
static get valueSymbol() {
61+
return valueSymbol;
3562
}
3663
}

event.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export class SearchParamChangeEvent extends CustomEvent {}

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@aegisjsproject/url",
3-
"version": "1.0.1",
3+
"version": "1.0.2",
44
"description": "Safe URL parsing/escaping via JS tagged templates",
55
"keywords": [
66
"aegis",

search.js

Lines changed: 92 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { SearchParam } from './SearchParam.js';
2+
import { SearchParamChangeEvent } from './event.js';
23

34
/**
45
* Factory function for `SearchParam`
@@ -12,51 +13,119 @@ import { SearchParam } from './SearchParam.js';
1213
* @param {boolean} [once=false] A boolean value that, if true, indicates that the function specified by listener will never call `preventDefault()`.
1314
* @returns {SearchParam} An instance of a `SearchParam` object, dispatching a `change` event when changed
1415
*/
15-
export function getSearch(key, fallbackValue, onChange, { signal, passive = false, once = false } = {}) {
16+
export function getSearch(key, fallbackValue, {
17+
onChange,
18+
onBeforeChange,
19+
multiple = false,
20+
signal,
21+
passive = false,
22+
once = false,
23+
} = {}) {
24+
const param = new SearchParam(key, fallbackValue, { multiple });
25+
1626
if (onChange instanceof Function) {
17-
const param = new SearchParam(key, fallbackValue);
1827
param.addEventListener('change', onChange, { signal, passive, once });
19-
return param;
20-
} else {
21-
return new SearchParam(key, fallbackValue);
2228
}
29+
30+
if (onBeforeChange instanceof Function) {
31+
param.addEventListener('beforechange', onBeforeChange, { signal, passive, once });
32+
}
33+
return param;
2334
}
2435

2536
/**
26-
* Manages a specified URL search parameter as a live-updating stateful value.
37+
* Manages search parameters in the URL with custom behavior for changes and updates.
2738
*
28-
* @param {string} key - The name of the URL search parameter to manage.
29-
* @param {string|number} [fallbackValue=''] - The initial/fallback value if the search parameter is not set.
30-
* @returns {[SearchParam, function(string|number): void]} - Returns a two-element array:
31-
* - Returns a two-element array:
32-
* - The first element is an object with:
33-
* - A `toString` method, returning the current value of the URL parameter as a string.
34-
* - A `[Symbol.toPrimitive]` method, allowing automatic conversion of the value based on the context (e.g., string or number).
35-
* - 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.
39+
* @param {string} key - The name of the search parameter to manage.
40+
* @param {string|Array} [fallbackValue=''] - The default value to use if the parameter is not present.
41+
* @param {Object} [options={}] - Optional configuration for managing the search parameter.
42+
* @param {function(SearchParamChangeEvent)} [options.onChange] - Callback invoked when the search parameter value changes.
43+
* @param {function(SearchParamChangeEvent)} [options.onBeforeChange] - Callback invoked before the search parameter value changes.
44+
* @param {boolean} [options.multiple=false] - Whether the parameter supports multiple values.
45+
* @param {AbortSignal} [options.signal] - An AbortSignal to cancel ongoing operations.
46+
* @param {boolean} [options.passive] - If true, changes to the parameter are not actively managed.
47+
* @param {boolean} [options.once] - If true, the parameter management is only performed once.
48+
* @returns {[Proxy<string | string[]>, function(newValue: *, options?: { method?: 'replace' | 'push', cause?: * }): void]} - An array containing two elements:
49+
* - A `Proxy` object that interacts with the search parameter's value.
50+
* - A function to update the search parameter, accepting a new value and options.
51+
* - @param {*} newValue - The new value to set for the search parameter.
52+
* - @param {Object} [updateOptions={}] - Options for the update operation.
53+
* - @param {'replace'|'push'} [updateOptions.method='replace'] - How to update the browser history.
54+
* - @param {*} [updateOptions.cause=null] - Additional context or reason for the update.
55+
* @throws {TypeError} - Throws if an invalid update method is provided.
3656
*/
37-
export function manageSearch(key, fallbackValue = '', onChange, { signal, passive, once } = {}) {
38-
const param = getSearch(key, fallbackValue, onChange, { once, passive, signal });
57+
export function manageSearch(key, fallbackValue = '', {
58+
onChange,
59+
onBeforeChange,
60+
multiple = false,
61+
signal,
62+
passive,
63+
once,
64+
} = {}) {
65+
const param = getSearch(key, fallbackValue, { onChange, onBeforeChange, multiple, once, passive, signal });
3966

4067
return [
41-
param,
68+
new Proxy(param, {
69+
get(target, prop) {
70+
if (prop === 'addEventListener') {
71+
return target.addEventListener.bind(target);
72+
} else {
73+
const val = target[SearchParam.valueSymbol];
74+
75+
if (typeof val === 'string') {
76+
return val[prop] instanceof Function ? val[prop].bind(val) : val[prop];
77+
} else {
78+
return Reflect.get(target[SearchParam.valueSymbol], prop, target[SearchParam.valueSymbol]);
79+
}
80+
}
81+
},
82+
has(target, prop) {
83+
return Reflect.has(target[SearchParam.valueSymbol], prop);
84+
},
85+
ownKeys(target) {
86+
return Reflect.ownKeys(target[SearchParam.valueSymbol]);
87+
},
88+
isExtensible(target) {
89+
return Reflect.isExtensible(target);
90+
},
91+
preventExtensions(target) {
92+
return Reflect.preventExtensions(target);
93+
},
94+
getOwnPropertyDescriptor(target, prop) {
95+
return Reflect.getOwnPropertyDescriptor(target[SearchParam.valueSymbol], prop);
96+
}
97+
}),
4298
(newValue, { method = 'replace', cause = null } = {}) => {
4399
const url = new URL(globalThis?.location?.href);
44-
const oldValue = url.searchParams.get(key);
45-
url.searchParams.set(key, newValue);
100+
const oldValue = url.searchParams.get(key) ?? fallbackValue;
46101

47-
const event = new CustomEvent('change', {
48-
cancelable: true,
49-
detail: { name: key, newValue, oldValue, method, url, cause },
50-
});
102+
if (multiple && typeof newValue === 'object' && newValue[Symbol.iterator] instanceof Function) {
103+
url.searchParams.delete(key);
104+
105+
for (const val of newValue) {
106+
url.searchParams.append(key, val);
107+
}
108+
} else if (typeof newValue === 'undefined') {
109+
url.searchParams.delete(key);
110+
} else {
111+
url.searchParams.set(key, newValue);
112+
}
113+
114+
const detail = Object.freeze({ name: key, newValue, oldValue, method, url, cause });
115+
const event = new SearchParamChangeEvent('beforechange', { cancelable: true, detail });
51116

52117
param.dispatchEvent(event);
53118

54119
if (event.defaultPrevented) {
55120
return;
56121
} else if (method === 'replace') {
57122
history.replaceState(history.state, '', url.href);
123+
124+
param.dispatchEvent(new SearchParamChangeEvent('change', { cancelable: false, detail }));
58125
} else if (method === 'push') {
59126
history.pushState(history.state, '', url.href);
127+
128+
param.dispatchEvent(new SearchParamChangeEvent('change', { cancelable: false, detail }));
60129
} else {
61130
throw new TypeError(`Invalid update method: ${method}.`);
62131
}

search.test.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, test } from 'node:test';
2+
import { ok, strictEqual, deepStrictEqual, throws, doesNotReject, rejects } from 'node:assert';
3+
import { manageSearch } from './search.js';
4+
5+
describe('Test `manageSearch()` functionality', () => {
6+
// Need to polyfill parts of `location` and `history` APIs for node.
7+
globalThis.location = new URL(import.meta.url);
8+
globalThis.history = {
9+
state: null,
10+
replaceState(state, unused, url) {
11+
this.state = state;
12+
globalThis.location = new URL(url, location);
13+
}
14+
};
15+
16+
test('Test basic functionality', async () => {
17+
const [param, setParam] = manageSearch('test');
18+
const signal = AbortSignal.timeout(1);
19+
ok(param.addEventListener instanceof Function, 'Should support event listeners.');
20+
21+
const promise = new Promise((resolve, reject) => {
22+
signal.addEventListener('abort', ({ target }) => reject(target.reason), { once: true });
23+
param.addEventListener('change', resolve, { signal, once: true });
24+
});
25+
26+
doesNotReject(() => promise, 'Events should dispatch.');
27+
setParam('works');
28+
setParam(undefined);
29+
});
30+
31+
test('Assure rejections work', async () => {
32+
const [param] = manageSearch('test');
33+
const signal = AbortSignal.timeout(1);
34+
35+
const promise = new Promise((resolve, reject) => {
36+
signal.addEventListener('abort', ({ target }) => reject(target.reason), { once: true });
37+
param.addEventListener('change', resolve, { signal });
38+
});
39+
40+
rejects(() => promise, 'Events should dispatch.');
41+
});
42+
43+
test('Test single string params', () => {
44+
const [name, setName] = manageSearch('name', '');
45+
strictEqual(name.length, 0, 'Should proxy to underlying string length.');
46+
setName('Fred');
47+
ok(name.substring instanceof Function, 'Methods of params should be proxied to values.');
48+
strictEqual(name.substring(0), 'Fred', 'Updating param should update the value.');
49+
strictEqual(name.length, 4, 'Updating param should update the value length.');
50+
strictEqual(location.search, '?name=Fred', 'Should update location correctly.');
51+
setName(undefined);
52+
strictEqual(location.search.length, 0, 'Setting no/empty values should remove the search param.');
53+
});
54+
55+
test('Test arrays and multiple values.', () => {
56+
const [list, setList] = manageSearch('list', [], { multiple: true });
57+
strictEqual(list.length, 0, 'Should start off with a length of 0');
58+
setList(['one', 'two', 'three']);
59+
deepStrictEqual(list.map(item => item.toUpperCase()), ['ONE', 'TWO', 'THREE'], 'Should expose underlying methods of array.');
60+
setList([...list, 'four']);
61+
throws(() => list.push('five'), { name: 'TypeError' }, 'Params should be immutable.');
62+
strictEqual(list.length, 4, 'Should implement the iterator protocol and spread syntax, updating values.');
63+
strictEqual(location.search, '?list=one&list=two&list=three&list=four', 'Should update the `list` search param with multiple values.');
64+
setList([]);
65+
strictEqual(location.search.length, 0, 'Setting no/empty values should remove the search param.');
66+
});
67+
});

url.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { url, createURLParser } from './parser.js';
22
export { SearchParam } from './SearchParam.js';
33
export { getSearch, manageSearch } from './search.js';
4+
export { SearchParamChangeEvent } from './event.js';

0 commit comments

Comments
 (0)