@bquery/bquery 1.7.0 → 1.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +760 -716
- package/dist/{a11y-C5QOVvRn.js → a11y-DVBCy09c.js} +3 -3
- package/dist/a11y-DVBCy09c.js.map +1 -0
- package/dist/a11y.es.mjs +1 -1
- package/dist/component/library.d.ts.map +1 -1
- package/dist/{component-CuuTijA6.js → component-L3-JfOFz.js} +5 -5
- package/dist/component-L3-JfOFz.js.map +1 -0
- package/dist/component.es.mjs +1 -1
- package/dist/{config-BW35FKuA.js → config-DhT9auRm.js} +1 -1
- package/dist/{config-BW35FKuA.js.map → config-DhT9auRm.js.map} +1 -1
- package/dist/{constraints-3lV9yyBw.js → constraints-D5RHQLmP.js} +1 -1
- package/dist/constraints-D5RHQLmP.js.map +1 -0
- package/dist/core/collection.d.ts +86 -0
- package/dist/core/collection.d.ts.map +1 -1
- package/dist/core/element.d.ts +28 -0
- package/dist/core/element.d.ts.map +1 -1
- package/dist/core/shared.d.ts +6 -0
- package/dist/core/shared.d.ts.map +1 -1
- package/dist/core-DdtZHzsS.js +168 -0
- package/dist/core-DdtZHzsS.js.map +1 -0
- package/dist/{core-Cjl7GUu8.js → core-EMYSLzaT.js} +289 -259
- package/dist/core-EMYSLzaT.js.map +1 -0
- package/dist/core.es.mjs +48 -47
- package/dist/{custom-directives-7wAShnnd.js → custom-directives-Dr4C5lVV.js} +1 -1
- package/dist/custom-directives-Dr4C5lVV.js.map +1 -0
- package/dist/{devtools-D2fQLhDN.js → devtools-BhB2iDPT.js} +2 -2
- package/dist/devtools-BhB2iDPT.js.map +1 -0
- package/dist/devtools.es.mjs +1 -1
- package/dist/{dnd-B8EgyzaI.js → dnd-NwZBYh4l.js} +1 -1
- package/dist/dnd-NwZBYh4l.js.map +1 -0
- package/dist/dnd.es.mjs +1 -1
- package/dist/{env-NeVmr4Gf.js → env-CTdvLaH2.js} +1 -1
- package/dist/env-CTdvLaH2.js.map +1 -0
- package/dist/forms/create-form.d.ts.map +1 -1
- package/dist/forms/index.d.ts +3 -2
- package/dist/forms/index.d.ts.map +1 -1
- package/dist/forms/types.d.ts +46 -0
- package/dist/forms/types.d.ts.map +1 -1
- package/dist/forms/use-field.d.ts +34 -0
- package/dist/forms/use-field.d.ts.map +1 -0
- package/dist/forms/validators.d.ts +25 -0
- package/dist/forms/validators.d.ts.map +1 -1
- package/dist/forms-UcRHsYxC.js +227 -0
- package/dist/forms-UcRHsYxC.js.map +1 -0
- package/dist/forms.es.mjs +14 -12
- package/dist/full.d.ts +17 -26
- package/dist/full.d.ts.map +1 -1
- package/dist/full.es.mjs +206 -181
- package/dist/full.iife.js +33 -33
- package/dist/full.iife.js.map +1 -1
- package/dist/full.umd.js +33 -33
- package/dist/full.umd.js.map +1 -1
- package/dist/function-Cybd57JV.js +33 -0
- package/dist/function-Cybd57JV.js.map +1 -0
- package/dist/{i18n-BnnhTFOS.js → i18n-kuF6Ekj6.js} +3 -3
- package/dist/i18n-kuF6Ekj6.js.map +1 -0
- package/dist/i18n.es.mjs +1 -1
- package/dist/index.es.mjs +251 -228
- package/dist/media/breakpoints.d.ts.map +1 -1
- package/dist/media/types.d.ts +2 -2
- package/dist/media/types.d.ts.map +1 -1
- package/dist/{media-Di2Ta22s.js → media-i-fB5WxI.js} +3 -3
- package/dist/media-i-fB5WxI.js.map +1 -0
- package/dist/media.es.mjs +1 -1
- package/dist/{motion-qPj_TYGv.js → motion-BJsAuULb.js} +2 -2
- package/dist/motion-BJsAuULb.js.map +1 -0
- package/dist/motion.es.mjs +1 -1
- package/dist/{mount-SM07RUa6.js → mount-B4Y8bk8Z.js} +5 -5
- package/dist/mount-B4Y8bk8Z.js.map +1 -0
- package/dist/{platform-CPbCprb6.js → platform-Dw2gE3zI.js} +3 -3
- package/dist/{platform-CPbCprb6.js.map → platform-Dw2gE3zI.js.map} +1 -1
- package/dist/platform.es.mjs +2 -2
- package/dist/plugin/registry.d.ts.map +1 -1
- package/dist/{plugin-cPoOHFLY.js → plugin-C2WuC8SF.js} +20 -18
- package/dist/plugin-C2WuC8SF.js.map +1 -0
- package/dist/plugin.es.mjs +1 -1
- package/dist/reactive/async-data.d.ts +28 -3
- package/dist/reactive/async-data.d.ts.map +1 -1
- package/dist/reactive/computed.d.ts +3 -0
- package/dist/reactive/computed.d.ts.map +1 -1
- package/dist/reactive/effect.d.ts +3 -0
- package/dist/reactive/effect.d.ts.map +1 -1
- package/dist/reactive/http.d.ts +194 -0
- package/dist/reactive/http.d.ts.map +1 -0
- package/dist/reactive/index.d.ts +2 -2
- package/dist/reactive/index.d.ts.map +1 -1
- package/dist/reactive/pagination.d.ts +126 -0
- package/dist/reactive/pagination.d.ts.map +1 -0
- package/dist/reactive/polling.d.ts +55 -0
- package/dist/reactive/polling.d.ts.map +1 -0
- package/dist/reactive/readonly.d.ts +20 -1
- package/dist/reactive/readonly.d.ts.map +1 -1
- package/dist/reactive/rest.d.ts +293 -0
- package/dist/reactive/rest.d.ts.map +1 -0
- package/dist/reactive/scope.d.ts +140 -0
- package/dist/reactive/scope.d.ts.map +1 -0
- package/dist/reactive/signal.d.ts +16 -2
- package/dist/reactive/signal.d.ts.map +1 -1
- package/dist/reactive/to-value.d.ts +57 -0
- package/dist/reactive/to-value.d.ts.map +1 -0
- package/dist/reactive/websocket.d.ts +285 -0
- package/dist/reactive/websocket.d.ts.map +1 -0
- package/dist/reactive-DwkhUJfP.js +1148 -0
- package/dist/reactive-DwkhUJfP.js.map +1 -0
- package/dist/reactive.es.mjs +38 -19
- package/dist/{registry-CWf368tT.js → registry-B08iilIh.js} +1 -1
- package/dist/{registry-CWf368tT.js.map → registry-B08iilIh.js.map} +1 -1
- package/dist/router/constraints.d.ts.map +1 -1
- package/dist/router/index.d.ts +1 -1
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/state.d.ts +25 -2
- package/dist/router/state.d.ts.map +1 -1
- package/dist/router-CQikC9Ed.js +492 -0
- package/dist/router-CQikC9Ed.js.map +1 -0
- package/dist/router.es.mjs +9 -8
- package/dist/ssr/hydrate.d.ts.map +1 -1
- package/dist/{ssr-B2qd_WBB.js → ssr-_dAcGdzu.js} +4 -4
- package/dist/ssr-_dAcGdzu.js.map +1 -0
- package/dist/ssr.es.mjs +1 -1
- package/dist/store/persisted.d.ts.map +1 -1
- package/dist/{store-DWpyH6p5.js → store-Cb3gPRve.js} +7 -7
- package/dist/store-Cb3gPRve.js.map +1 -0
- package/dist/store.es.mjs +2 -2
- package/dist/storybook.es.mjs.map +1 -1
- package/dist/{testing-CsqjNUyy.js → testing-C5Sjfsna.js} +8 -8
- package/dist/testing-C5Sjfsna.js.map +1 -0
- package/dist/testing.es.mjs +1 -1
- package/dist/{type-guards-Do9DWgNp.js → type-guards-BMX2c0LP.js} +1 -1
- package/dist/{type-guards-Do9DWgNp.js.map → type-guards-BMX2c0LP.js.map} +1 -1
- package/dist/untrack-D0fnO5k2.js +36 -0
- package/dist/untrack-D0fnO5k2.js.map +1 -0
- package/dist/view/custom-directives.d.ts.map +1 -1
- package/dist/view.es.mjs +4 -4
- package/package.json +178 -177
- package/src/a11y/announce.ts +131 -131
- package/src/a11y/audit.ts +314 -314
- package/src/a11y/index.ts +68 -68
- package/src/a11y/media-preferences.ts +255 -255
- package/src/a11y/roving-tab-index.ts +164 -164
- package/src/a11y/skip-link.ts +255 -255
- package/src/a11y/trap-focus.ts +184 -184
- package/src/a11y/types.ts +183 -183
- package/src/component/component.ts +599 -599
- package/src/component/html.ts +153 -153
- package/src/component/index.ts +52 -52
- package/src/component/library.ts +540 -542
- package/src/component/scope.ts +212 -212
- package/src/component/types.ts +310 -310
- package/src/core/collection.ts +876 -707
- package/src/core/element.ts +1015 -981
- package/src/core/env.ts +60 -60
- package/src/core/index.ts +49 -49
- package/src/core/shared.ts +77 -62
- package/src/core/utils/index.ts +148 -148
- package/src/devtools/devtools.ts +410 -410
- package/src/devtools/index.ts +48 -48
- package/src/devtools/types.ts +104 -104
- package/src/dnd/draggable.ts +296 -296
- package/src/dnd/droppable.ts +228 -228
- package/src/dnd/index.ts +62 -62
- package/src/dnd/sortable.ts +307 -307
- package/src/dnd/types.ts +293 -293
- package/src/forms/create-form.ts +320 -278
- package/src/forms/index.ts +70 -65
- package/src/forms/types.ts +203 -154
- package/src/forms/use-field.ts +231 -0
- package/src/forms/validators.ts +294 -265
- package/src/full.ts +554 -480
- package/src/i18n/formatting.ts +67 -67
- package/src/i18n/i18n.ts +200 -200
- package/src/i18n/index.ts +67 -67
- package/src/i18n/translate.ts +182 -182
- package/src/i18n/types.ts +171 -171
- package/src/index.ts +108 -108
- package/src/media/battery.ts +116 -116
- package/src/media/breakpoints.ts +129 -131
- package/src/media/clipboard.ts +80 -80
- package/src/media/device-sensors.ts +158 -158
- package/src/media/geolocation.ts +119 -119
- package/src/media/index.ts +76 -76
- package/src/media/media-query.ts +92 -92
- package/src/media/network.ts +115 -115
- package/src/media/types.ts +177 -177
- package/src/media/viewport.ts +84 -84
- package/src/motion/index.ts +57 -57
- package/src/motion/morph.ts +151 -151
- package/src/motion/parallax.ts +120 -120
- package/src/motion/reduced-motion.ts +66 -66
- package/src/motion/types.ts +271 -271
- package/src/motion/typewriter.ts +164 -164
- package/src/plugin/index.ts +37 -37
- package/src/plugin/registry.ts +284 -269
- package/src/plugin/types.ts +137 -137
- package/src/reactive/async-data.ts +250 -29
- package/src/reactive/computed.ts +144 -130
- package/src/reactive/effect.ts +29 -6
- package/src/reactive/http.ts +790 -0
- package/src/reactive/index.ts +60 -0
- package/src/reactive/pagination.ts +317 -0
- package/src/reactive/polling.ts +179 -0
- package/src/reactive/readonly.ts +52 -8
- package/src/reactive/rest.ts +859 -0
- package/src/reactive/scope.ts +276 -0
- package/src/reactive/signal.ts +61 -1
- package/src/reactive/to-value.ts +71 -0
- package/src/reactive/websocket.ts +849 -0
- package/src/router/bq-link.ts +279 -279
- package/src/router/constraints.ts +204 -201
- package/src/router/index.ts +49 -49
- package/src/router/match.ts +312 -312
- package/src/router/path-pattern.ts +52 -52
- package/src/router/query.ts +38 -38
- package/src/router/router.ts +421 -402
- package/src/router/state.ts +51 -3
- package/src/router/types.ts +139 -139
- package/src/router/use-route.ts +68 -68
- package/src/router/utils.ts +157 -157
- package/src/security/index.ts +12 -12
- package/src/ssr/hydrate.ts +84 -82
- package/src/ssr/index.ts +70 -70
- package/src/ssr/render.ts +508 -508
- package/src/ssr/serialize.ts +296 -296
- package/src/ssr/types.ts +81 -81
- package/src/store/create-store.ts +467 -467
- package/src/store/index.ts +27 -27
- package/src/store/persisted.ts +245 -249
- package/src/store/types.ts +247 -247
- package/src/store/utils.ts +135 -135
- package/src/storybook/index.ts +480 -480
- package/src/testing/index.ts +42 -42
- package/src/testing/testing.ts +593 -593
- package/src/testing/types.ts +170 -170
- package/src/view/custom-directives.ts +28 -30
- package/src/view/evaluate.ts +292 -292
- package/src/view/process.ts +108 -108
- package/dist/a11y-C5QOVvRn.js.map +0 -1
- package/dist/component-CuuTijA6.js.map +0 -1
- package/dist/constraints-3lV9yyBw.js.map +0 -1
- package/dist/core-Cjl7GUu8.js.map +0 -1
- package/dist/core-DnlyjbF2.js +0 -112
- package/dist/core-DnlyjbF2.js.map +0 -1
- package/dist/custom-directives-7wAShnnd.js.map +0 -1
- package/dist/devtools-D2fQLhDN.js.map +0 -1
- package/dist/dnd-B8EgyzaI.js.map +0 -1
- package/dist/env-NeVmr4Gf.js.map +0 -1
- package/dist/forms-C3yovgH9.js +0 -141
- package/dist/forms-C3yovgH9.js.map +0 -1
- package/dist/i18n-BnnhTFOS.js.map +0 -1
- package/dist/media-Di2Ta22s.js.map +0 -1
- package/dist/motion-qPj_TYGv.js.map +0 -1
- package/dist/mount-SM07RUa6.js.map +0 -1
- package/dist/plugin-cPoOHFLY.js.map +0 -1
- package/dist/reactive-Cfv0RK6x.js +0 -233
- package/dist/reactive-Cfv0RK6x.js.map +0 -1
- package/dist/router-BrthaP_z.js +0 -473
- package/dist/router-BrthaP_z.js.map +0 -1
- package/dist/ssr-B2qd_WBB.js.map +0 -1
- package/dist/store-DWpyH6p5.js.map +0 -1
- package/dist/testing-CsqjNUyy.js.map +0 -1
- package/dist/untrack-DJVQQ2WM.js +0 -33
- package/dist/untrack-DJVQQ2WM.js.map +0 -1
package/src/router/bq-link.ts
CHANGED
|
@@ -1,279 +1,279 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `<bq-link>` custom element for declarative SPA navigation.
|
|
3
|
-
*
|
|
4
|
-
* Exposes an accessible custom element that behaves like a link for
|
|
5
|
-
* client-side routing. Automatically toggles an active class when the
|
|
6
|
-
* target path matches the current route.
|
|
7
|
-
*
|
|
8
|
-
* @module bquery/router
|
|
9
|
-
*
|
|
10
|
-
* @example
|
|
11
|
-
* ```html
|
|
12
|
-
* <bq-link to="/">Home</bq-link>
|
|
13
|
-
* <bq-link to="/about" active-class="selected">About</bq-link>
|
|
14
|
-
* <bq-link to="/settings" replace exact>Settings</bq-link>
|
|
15
|
-
* ```
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { effect, type CleanupFn } from '../reactive/index';
|
|
19
|
-
import { navigate } from './navigation';
|
|
20
|
-
import { getActiveRouter, routeSignal } from './state';
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Default CSS class applied when the link's target path is active.
|
|
24
|
-
* @internal
|
|
25
|
-
*/
|
|
26
|
-
const DEFAULT_ACTIVE_CLASS = 'active';
|
|
27
|
-
|
|
28
|
-
/** @internal */
|
|
29
|
-
const tokenizeClassNames = (value: string): string[] => {
|
|
30
|
-
return value
|
|
31
|
-
.split(/\s+/)
|
|
32
|
-
.map((token) => token.trim())
|
|
33
|
-
.filter((token) => token.length > 0);
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
/** @internal SSR-safe base class for environments without HTMLElement. */
|
|
37
|
-
const BQ_LINK_BASE =
|
|
38
|
-
typeof HTMLElement !== 'undefined' ? HTMLElement : (class {} as unknown as typeof HTMLElement);
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* `<bq-link>` — A navigation custom element for bQuery routers.
|
|
42
|
-
*
|
|
43
|
-
* Attributes:
|
|
44
|
-
* - `to` — Target path (required). Example: `to="/dashboard"`.
|
|
45
|
-
* - `replace` — If present, replaces the current history entry instead of pushing.
|
|
46
|
-
* - `exact` — If present, the active class is only applied on an exact path match.
|
|
47
|
-
* - `active-class` — CSS class added when the route is active (default: `'active'`).
|
|
48
|
-
*
|
|
49
|
-
* The custom element itself acts as the interactive link target using
|
|
50
|
-
* `role="link"` and keyboard handling. It does not render a native `<a>`,
|
|
51
|
-
* so browser-native link affordances like context-menu "open in new tab"
|
|
52
|
-
* are not provided automatically.
|
|
53
|
-
*
|
|
54
|
-
* @example
|
|
55
|
-
* ```ts
|
|
56
|
-
* import { registerBqLink } from '@bquery/bquery/router';
|
|
57
|
-
*
|
|
58
|
-
* // Register the <bq-link> element (idempotent)
|
|
59
|
-
* registerBqLink();
|
|
60
|
-
*
|
|
61
|
-
* // Then use in HTML:
|
|
62
|
-
* // <bq-link to="/about">About</bq-link>
|
|
63
|
-
* ```
|
|
64
|
-
*/
|
|
65
|
-
export class BqLinkElement extends BQ_LINK_BASE {
|
|
66
|
-
/** @internal */
|
|
67
|
-
private _cleanup: CleanupFn | null = null;
|
|
68
|
-
|
|
69
|
-
/** @internal */
|
|
70
|
-
private _trackedActiveClasses = new Map<string, boolean>();
|
|
71
|
-
|
|
72
|
-
static get observedAttributes(): string[] {
|
|
73
|
-
return ['to', 'replace', 'exact', 'active-class'];
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** The target path for navigation. */
|
|
77
|
-
get to(): string {
|
|
78
|
-
const to = this.getAttribute('to');
|
|
79
|
-
return to == null || to.trim() === '' ? '/' : to;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
set to(value: string) {
|
|
83
|
-
this.setAttribute('to', value);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Whether to replace the current history entry. */
|
|
87
|
-
get replace(): boolean {
|
|
88
|
-
return this.hasAttribute('replace');
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
set replace(value: boolean) {
|
|
92
|
-
if (value) {
|
|
93
|
-
this.setAttribute('replace', '');
|
|
94
|
-
} else {
|
|
95
|
-
this.removeAttribute('replace');
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Whether to match the path exactly for active class. */
|
|
100
|
-
get exact(): boolean {
|
|
101
|
-
return this.hasAttribute('exact');
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
set exact(value: boolean) {
|
|
105
|
-
if (value) {
|
|
106
|
-
this.setAttribute('exact', '');
|
|
107
|
-
} else {
|
|
108
|
-
this.removeAttribute('exact');
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** CSS class applied when the route is active. */
|
|
113
|
-
get activeClass(): string {
|
|
114
|
-
return this.getAttribute('active-class') ?? DEFAULT_ACTIVE_CLASS;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
set activeClass(value: string) {
|
|
118
|
-
this.setAttribute('active-class', value);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** @internal */
|
|
122
|
-
connectedCallback(): void {
|
|
123
|
-
// Set role for accessibility if not an <a> already
|
|
124
|
-
if (!this.getAttribute('role')) {
|
|
125
|
-
this.setAttribute('role', 'link');
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Make focusable if not already
|
|
129
|
-
if (!this.hasAttribute('tabindex')) {
|
|
130
|
-
this.setAttribute('tabindex', '0');
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Attach click handler
|
|
134
|
-
this.addEventListener('click', this._handleClick);
|
|
135
|
-
this.addEventListener('keydown', this._handleKeydown);
|
|
136
|
-
|
|
137
|
-
// Set up reactive active-class tracking
|
|
138
|
-
this._setupActiveTracking();
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** @internal */
|
|
142
|
-
disconnectedCallback(): void {
|
|
143
|
-
this.removeEventListener('click', this._handleClick);
|
|
144
|
-
this.removeEventListener('keydown', this._handleKeydown);
|
|
145
|
-
|
|
146
|
-
if (this._cleanup) {
|
|
147
|
-
this._cleanup();
|
|
148
|
-
this._cleanup = null;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
this._clearTrackedActiveClasses();
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** @internal */
|
|
155
|
-
attributeChangedCallback(name: string, _oldValue: string | null, _newValue: string | null): void {
|
|
156
|
-
// Re-setup active tracking when relevant attributes change
|
|
157
|
-
if (name === 'to' || name === 'exact' || name === 'active-class') {
|
|
158
|
-
if (this.isConnected) {
|
|
159
|
-
this._setupActiveTracking();
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Sets up the reactive effect that toggles the active CSS class
|
|
166
|
-
* based on the current route.
|
|
167
|
-
* @internal
|
|
168
|
-
*/
|
|
169
|
-
private _setupActiveTracking(): void {
|
|
170
|
-
// Clean up previous effect
|
|
171
|
-
if (this._cleanup) {
|
|
172
|
-
this._cleanup();
|
|
173
|
-
this._cleanup = null;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
this._clearTrackedActiveClasses();
|
|
177
|
-
|
|
178
|
-
const targetPath = this.to;
|
|
179
|
-
const exactMatch = this.exact;
|
|
180
|
-
const cssClasses = tokenizeClassNames(this.activeClass);
|
|
181
|
-
this._trackedActiveClasses = new Map(
|
|
182
|
-
cssClasses.map((cssClass) => [cssClass, this.classList.contains(cssClass)])
|
|
183
|
-
);
|
|
184
|
-
|
|
185
|
-
this._cleanup = effect(() => {
|
|
186
|
-
const current = routeSignal.value.path;
|
|
187
|
-
const isMatch = exactMatch
|
|
188
|
-
? current === targetPath
|
|
189
|
-
: targetPath === '/'
|
|
190
|
-
? current === '/'
|
|
191
|
-
: current === targetPath ||
|
|
192
|
-
current.startsWith(targetPath.endsWith('/') ? targetPath : targetPath + '/');
|
|
193
|
-
|
|
194
|
-
for (const cssClass of cssClasses) {
|
|
195
|
-
const wasPresentInitially = this._trackedActiveClasses.get(cssClass) ?? false;
|
|
196
|
-
this.classList.toggle(cssClass, isMatch || wasPresentInitially);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Update aria-current for accessibility
|
|
200
|
-
if (isMatch) {
|
|
201
|
-
this.setAttribute('aria-current', 'page');
|
|
202
|
-
} else {
|
|
203
|
-
this.removeAttribute('aria-current');
|
|
204
|
-
}
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** @internal */
|
|
209
|
-
private _clearTrackedActiveClasses(): void {
|
|
210
|
-
for (const [cssClass, wasPresentInitially] of this._trackedActiveClasses) {
|
|
211
|
-
this.classList.toggle(cssClass, wasPresentInitially);
|
|
212
|
-
}
|
|
213
|
-
this._trackedActiveClasses.clear();
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Handles click events for SPA navigation.
|
|
218
|
-
* @internal
|
|
219
|
-
*/
|
|
220
|
-
private _handleClick = (e: Event): void => {
|
|
221
|
-
if (!(e instanceof MouseEvent)) return;
|
|
222
|
-
if (e.defaultPrevented) return;
|
|
223
|
-
if (e.button !== 0) return; // Only left clicks
|
|
224
|
-
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
|
|
225
|
-
|
|
226
|
-
e.preventDefault();
|
|
227
|
-
this._navigate();
|
|
228
|
-
};
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Handles keyboard activation (Enter).
|
|
232
|
-
* @internal
|
|
233
|
-
*/
|
|
234
|
-
private _handleKeydown = (e: Event): void => {
|
|
235
|
-
if (e instanceof KeyboardEvent && e.key === 'Enter') {
|
|
236
|
-
e.preventDefault();
|
|
237
|
-
this._navigate();
|
|
238
|
-
}
|
|
239
|
-
};
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Performs the actual navigation.
|
|
243
|
-
* @internal
|
|
244
|
-
*/
|
|
245
|
-
private _navigate(): void {
|
|
246
|
-
const targetPath = this.to;
|
|
247
|
-
if (!targetPath) return;
|
|
248
|
-
if (!getActiveRouter()) return;
|
|
249
|
-
|
|
250
|
-
void navigate(targetPath, { replace: this.replace }).catch((err) => {
|
|
251
|
-
console.error('bq-link: Navigation failed:', err);
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Registers the `<bq-link>` custom element.
|
|
258
|
-
*
|
|
259
|
-
* This function is idempotent — calling it multiple times is safe.
|
|
260
|
-
* The element is registered under the tag name `bq-link`.
|
|
261
|
-
*
|
|
262
|
-
* @example
|
|
263
|
-
* ```ts
|
|
264
|
-
* import { registerBqLink } from '@bquery/bquery/router';
|
|
265
|
-
*
|
|
266
|
-
* registerBqLink();
|
|
267
|
-
*
|
|
268
|
-
* // Now use <bq-link to="/about">About</bq-link> in HTML
|
|
269
|
-
* ```
|
|
270
|
-
*/
|
|
271
|
-
export const registerBqLink = (): void => {
|
|
272
|
-
if (
|
|
273
|
-
typeof HTMLElement !== 'undefined' &&
|
|
274
|
-
typeof customElements !== 'undefined' &&
|
|
275
|
-
!customElements.get('bq-link')
|
|
276
|
-
) {
|
|
277
|
-
customElements.define('bq-link', BqLinkElement);
|
|
278
|
-
}
|
|
279
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* `<bq-link>` custom element for declarative SPA navigation.
|
|
3
|
+
*
|
|
4
|
+
* Exposes an accessible custom element that behaves like a link for
|
|
5
|
+
* client-side routing. Automatically toggles an active class when the
|
|
6
|
+
* target path matches the current route.
|
|
7
|
+
*
|
|
8
|
+
* @module bquery/router
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```html
|
|
12
|
+
* <bq-link to="/">Home</bq-link>
|
|
13
|
+
* <bq-link to="/about" active-class="selected">About</bq-link>
|
|
14
|
+
* <bq-link to="/settings" replace exact>Settings</bq-link>
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { effect, type CleanupFn } from '../reactive/index';
|
|
19
|
+
import { navigate } from './navigation';
|
|
20
|
+
import { getActiveRouter, routeSignal } from './state';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Default CSS class applied when the link's target path is active.
|
|
24
|
+
* @internal
|
|
25
|
+
*/
|
|
26
|
+
const DEFAULT_ACTIVE_CLASS = 'active';
|
|
27
|
+
|
|
28
|
+
/** @internal */
|
|
29
|
+
const tokenizeClassNames = (value: string): string[] => {
|
|
30
|
+
return value
|
|
31
|
+
.split(/\s+/)
|
|
32
|
+
.map((token) => token.trim())
|
|
33
|
+
.filter((token) => token.length > 0);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** @internal SSR-safe base class for environments without HTMLElement. */
|
|
37
|
+
const BQ_LINK_BASE =
|
|
38
|
+
typeof HTMLElement !== 'undefined' ? HTMLElement : (class {} as unknown as typeof HTMLElement);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `<bq-link>` — A navigation custom element for bQuery routers.
|
|
42
|
+
*
|
|
43
|
+
* Attributes:
|
|
44
|
+
* - `to` — Target path (required). Example: `to="/dashboard"`.
|
|
45
|
+
* - `replace` — If present, replaces the current history entry instead of pushing.
|
|
46
|
+
* - `exact` — If present, the active class is only applied on an exact path match.
|
|
47
|
+
* - `active-class` — CSS class added when the route is active (default: `'active'`).
|
|
48
|
+
*
|
|
49
|
+
* The custom element itself acts as the interactive link target using
|
|
50
|
+
* `role="link"` and keyboard handling. It does not render a native `<a>`,
|
|
51
|
+
* so browser-native link affordances like context-menu "open in new tab"
|
|
52
|
+
* are not provided automatically.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```ts
|
|
56
|
+
* import { registerBqLink } from '@bquery/bquery/router';
|
|
57
|
+
*
|
|
58
|
+
* // Register the <bq-link> element (idempotent)
|
|
59
|
+
* registerBqLink();
|
|
60
|
+
*
|
|
61
|
+
* // Then use in HTML:
|
|
62
|
+
* // <bq-link to="/about">About</bq-link>
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export class BqLinkElement extends BQ_LINK_BASE {
|
|
66
|
+
/** @internal */
|
|
67
|
+
private _cleanup: CleanupFn | null = null;
|
|
68
|
+
|
|
69
|
+
/** @internal */
|
|
70
|
+
private _trackedActiveClasses = new Map<string, boolean>();
|
|
71
|
+
|
|
72
|
+
static get observedAttributes(): string[] {
|
|
73
|
+
return ['to', 'replace', 'exact', 'active-class'];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The target path for navigation. */
|
|
77
|
+
get to(): string {
|
|
78
|
+
const to = this.getAttribute('to');
|
|
79
|
+
return to == null || to.trim() === '' ? '/' : to;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
set to(value: string) {
|
|
83
|
+
this.setAttribute('to', value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Whether to replace the current history entry. */
|
|
87
|
+
get replace(): boolean {
|
|
88
|
+
return this.hasAttribute('replace');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
set replace(value: boolean) {
|
|
92
|
+
if (value) {
|
|
93
|
+
this.setAttribute('replace', '');
|
|
94
|
+
} else {
|
|
95
|
+
this.removeAttribute('replace');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Whether to match the path exactly for active class. */
|
|
100
|
+
get exact(): boolean {
|
|
101
|
+
return this.hasAttribute('exact');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
set exact(value: boolean) {
|
|
105
|
+
if (value) {
|
|
106
|
+
this.setAttribute('exact', '');
|
|
107
|
+
} else {
|
|
108
|
+
this.removeAttribute('exact');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** CSS class applied when the route is active. */
|
|
113
|
+
get activeClass(): string {
|
|
114
|
+
return this.getAttribute('active-class') ?? DEFAULT_ACTIVE_CLASS;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
set activeClass(value: string) {
|
|
118
|
+
this.setAttribute('active-class', value);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** @internal */
|
|
122
|
+
connectedCallback(): void {
|
|
123
|
+
// Set role for accessibility if not an <a> already
|
|
124
|
+
if (!this.getAttribute('role')) {
|
|
125
|
+
this.setAttribute('role', 'link');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Make focusable if not already
|
|
129
|
+
if (!this.hasAttribute('tabindex')) {
|
|
130
|
+
this.setAttribute('tabindex', '0');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Attach click handler
|
|
134
|
+
this.addEventListener('click', this._handleClick);
|
|
135
|
+
this.addEventListener('keydown', this._handleKeydown);
|
|
136
|
+
|
|
137
|
+
// Set up reactive active-class tracking
|
|
138
|
+
this._setupActiveTracking();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** @internal */
|
|
142
|
+
disconnectedCallback(): void {
|
|
143
|
+
this.removeEventListener('click', this._handleClick);
|
|
144
|
+
this.removeEventListener('keydown', this._handleKeydown);
|
|
145
|
+
|
|
146
|
+
if (this._cleanup) {
|
|
147
|
+
this._cleanup();
|
|
148
|
+
this._cleanup = null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this._clearTrackedActiveClasses();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** @internal */
|
|
155
|
+
attributeChangedCallback(name: string, _oldValue: string | null, _newValue: string | null): void {
|
|
156
|
+
// Re-setup active tracking when relevant attributes change
|
|
157
|
+
if (name === 'to' || name === 'exact' || name === 'active-class') {
|
|
158
|
+
if (this.isConnected) {
|
|
159
|
+
this._setupActiveTracking();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Sets up the reactive effect that toggles the active CSS class
|
|
166
|
+
* based on the current route.
|
|
167
|
+
* @internal
|
|
168
|
+
*/
|
|
169
|
+
private _setupActiveTracking(): void {
|
|
170
|
+
// Clean up previous effect
|
|
171
|
+
if (this._cleanup) {
|
|
172
|
+
this._cleanup();
|
|
173
|
+
this._cleanup = null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
this._clearTrackedActiveClasses();
|
|
177
|
+
|
|
178
|
+
const targetPath = this.to;
|
|
179
|
+
const exactMatch = this.exact;
|
|
180
|
+
const cssClasses = tokenizeClassNames(this.activeClass);
|
|
181
|
+
this._trackedActiveClasses = new Map(
|
|
182
|
+
cssClasses.map((cssClass) => [cssClass, this.classList.contains(cssClass)])
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
this._cleanup = effect(() => {
|
|
186
|
+
const current = routeSignal.value.path;
|
|
187
|
+
const isMatch = exactMatch
|
|
188
|
+
? current === targetPath
|
|
189
|
+
: targetPath === '/'
|
|
190
|
+
? current === '/'
|
|
191
|
+
: current === targetPath ||
|
|
192
|
+
current.startsWith(targetPath.endsWith('/') ? targetPath : targetPath + '/');
|
|
193
|
+
|
|
194
|
+
for (const cssClass of cssClasses) {
|
|
195
|
+
const wasPresentInitially = this._trackedActiveClasses.get(cssClass) ?? false;
|
|
196
|
+
this.classList.toggle(cssClass, isMatch || wasPresentInitially);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Update aria-current for accessibility
|
|
200
|
+
if (isMatch) {
|
|
201
|
+
this.setAttribute('aria-current', 'page');
|
|
202
|
+
} else {
|
|
203
|
+
this.removeAttribute('aria-current');
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** @internal */
|
|
209
|
+
private _clearTrackedActiveClasses(): void {
|
|
210
|
+
for (const [cssClass, wasPresentInitially] of this._trackedActiveClasses) {
|
|
211
|
+
this.classList.toggle(cssClass, wasPresentInitially);
|
|
212
|
+
}
|
|
213
|
+
this._trackedActiveClasses.clear();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Handles click events for SPA navigation.
|
|
218
|
+
* @internal
|
|
219
|
+
*/
|
|
220
|
+
private _handleClick = (e: Event): void => {
|
|
221
|
+
if (!(e instanceof MouseEvent)) return;
|
|
222
|
+
if (e.defaultPrevented) return;
|
|
223
|
+
if (e.button !== 0) return; // Only left clicks
|
|
224
|
+
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
|
|
225
|
+
|
|
226
|
+
e.preventDefault();
|
|
227
|
+
this._navigate();
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Handles keyboard activation (Enter).
|
|
232
|
+
* @internal
|
|
233
|
+
*/
|
|
234
|
+
private _handleKeydown = (e: Event): void => {
|
|
235
|
+
if (e instanceof KeyboardEvent && e.key === 'Enter') {
|
|
236
|
+
e.preventDefault();
|
|
237
|
+
this._navigate();
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Performs the actual navigation.
|
|
243
|
+
* @internal
|
|
244
|
+
*/
|
|
245
|
+
private _navigate(): void {
|
|
246
|
+
const targetPath = this.to;
|
|
247
|
+
if (!targetPath) return;
|
|
248
|
+
if (!getActiveRouter()) return;
|
|
249
|
+
|
|
250
|
+
void navigate(targetPath, { replace: this.replace }).catch((err) => {
|
|
251
|
+
console.error('bq-link: Navigation failed:', err);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Registers the `<bq-link>` custom element.
|
|
258
|
+
*
|
|
259
|
+
* This function is idempotent — calling it multiple times is safe.
|
|
260
|
+
* The element is registered under the tag name `bq-link`.
|
|
261
|
+
*
|
|
262
|
+
* @example
|
|
263
|
+
* ```ts
|
|
264
|
+
* import { registerBqLink } from '@bquery/bquery/router';
|
|
265
|
+
*
|
|
266
|
+
* registerBqLink();
|
|
267
|
+
*
|
|
268
|
+
* // Now use <bq-link to="/about">About</bq-link> in HTML
|
|
269
|
+
* ```
|
|
270
|
+
*/
|
|
271
|
+
export const registerBqLink = (): void => {
|
|
272
|
+
if (
|
|
273
|
+
typeof HTMLElement !== 'undefined' &&
|
|
274
|
+
typeof customElements !== 'undefined' &&
|
|
275
|
+
!customElements.get('bq-link')
|
|
276
|
+
) {
|
|
277
|
+
customElements.define('bq-link', BqLinkElement);
|
|
278
|
+
}
|
|
279
|
+
};
|