Skip to content

Commit cec707f

Browse files
authored
Merge pull request #4 from AegisJSProject/feature/root-commands
Add root command support and command utilities
2 parents 47f995a + b971e83 commit cec707f

8 files changed

Lines changed: 299 additions & 28 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.1] - 2025-10-08
11+
12+
### Added
13+
- Add commands registry for `document.documentElement` / `<html>` / `:root` / `:host`
14+
- Add support for passing arguments with command via `--command:arg1:arg2:...:argn`
15+
- Add predefined command for adding/removing classes
16+
17+
### Changed
18+
- Use `event.preventDefault()` and `event.stopImmediatePropagation()` for handled events
19+
1020
## [v1.0.0] - 2025-09-28
1121

1222
Initial Release

commands.js

Lines changed: 84 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { PREFIX, COMMANDS } from './consts.js';
1+
import { PREFIX, COMMANDS, ROOT_COMMANDS } from './consts.js';
2+
import { _disableSource, _enableSource, _finalizeEvent, getCommandWithArgs } from './utils.js';
23

34
/**
45
* @typedef {Event & {source: Element, command: string}} CommandEvent
@@ -15,105 +16,163 @@ let registrationOpen = true;
1516
* @param {CommandEvent} event The event to handle
1617
*/
1718
export function handleCommand(event) {
18-
switch(event.command) {
19+
const [command, ...args] = event.command.split(':');
20+
switch(command) {
21+
case COMMANDS.addClass:
22+
event.target.classList.add(...args);
23+
_finalizeEvent(event);
24+
break;
25+
26+
case COMMANDS.removeClass:
27+
event.target.classList.remove(...args);
28+
_finalizeEvent(event);
29+
break;
30+
1931
case COMMANDS.show:
2032
event.target.show();
33+
_finalizeEvent(event);
2134
break;
2235

2336
case COMMANDS.hide:
2437
event.target.hidden = true;
38+
_finalizeEvent(event);
2539
break;
2640

2741
case COMMANDS.unhide:
2842
event.target.hidden = false;
43+
_finalizeEvent(event);
2944
break;
3045

3146
case COMMANDS.disable:
3247
event.target.disabled = true;
48+
_finalizeEvent(event);
3349
break;
3450

3551
case COMMANDS.enable:
3652
event.target.disabled = false;
53+
_finalizeEvent(event);
3754
break;
3855

3956
case COMMANDS.scrollIntoView:
4057
event.target.scrollIntoView({
41-
behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth',
58+
behavior: event.target.dataset.behavior ?? matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth',
59+
block: event.target.dataset.block ?? 'start',
60+
container: event.target.dataset.container ?? 'all',
61+
inline: event.target.dataset.inline ?? 'nearest',
4262
});
63+
64+
_finalizeEvent(event);
4365
break;
4466

4567
case COMMANDS.remove:
4668
event.target.remove();
69+
_finalizeEvent(event);
4770
break;
4871

4972
case COMMANDS.requestFullscreen:
5073
event.target.requestFullscreen();
74+
_finalizeEvent(event);
5175
break;
5276

5377
case COMMANDS.exitFullscreen:
5478
if (event.target.isSameNode(document.fullscreenElement)) {
5579
document.exitFullscreen();
80+
_finalizeEvent(event);
5681
}
5782

5883
break;
5984

6085
case COMMANDS.toggleFullscreen:
6186
if (event.target.isSameNode(document.fullscreenElement)) {
6287
document.exitFullscreen();
88+
_finalizeEvent(event);
6389
} else {
6490
event.target.requestFullscreen();
91+
_finalizeEvent(event);
6592
}
6693
break;
6794

6895
case COMMANDS.showPicker:
6996
event.target.showPicker();
97+
_finalizeEvent(event);
7098
break;
7199

72100
case COMMANDS.stepUp:
73101
event.target.stepUp();
102+
_finalizeEvent(event);
74103
break;
75104

76105
case COMMANDS.stepDown:
77106
event.target.stepDown();
107+
_finalizeEvent(event);
78108
break;
79109

80110
case COMMANDS.openDetails:
81111
event.target.open = true;
112+
_finalizeEvent(event);
82113
break;
83114

84115
case COMMANDS.closeDetails:
85116
event.target.open = false;
117+
_finalizeEvent(event);
86118
break;
87119

88120
case COMMANDS.toggleDetails:
89121
event.target.open = ! event.target.open;
122+
_finalizeEvent(event);
90123
break;
91124

92125
case COMMANDS.playMedia:
93126
event.target.play();
127+
_finalizeEvent(event);
94128
break;
95129

96130
case COMMANDS.pauseMedia:
97131
event.target.pause();
132+
_finalizeEvent(event);
98133
break;
99134

100135

101136
case COMMANDS.requestPictureInPicture:
102137
event.target.requestPictureInPicture();
138+
_finalizeEvent(event);
103139
break;
104140

105141
case COMMANDS.copyText:
106-
navigator.clipboard.writeText(event.target.textContent);
142+
_disableSource(event);
143+
_finalizeEvent(event);
144+
navigator.clipboard.writeText(event.target.textContent).finally(() => _enableSource(event));
107145
break;
108146

109147
default:
110-
if (registeredCommands.has(event.command)) {
111-
const callback = registeredCommands.get(event.command);
112-
callback(event);
148+
if (registeredCommands.has(command)) {
149+
_disableSource(event);
150+
_finalizeEvent(event);
151+
152+
Promise.try(registeredCommands.get(command), event, ...args)
153+
.catch(globalThis.reportError)
154+
.finally(() => _enableSource(event));
113155
}
114156
}
115157
}
116158

159+
const COMMAND_VALUES = Object.values(COMMANDS);
160+
161+
/**
162+
* Checks if a command is registered
163+
*
164+
* @param {string} command The command to check for
165+
* @returns {boolean} If the command is registered
166+
*/
167+
export function hasCommand(command){
168+
const cmd = typeof command === 'string' ? command.split(':')[0] : null;
169+
170+
return typeof cmd === 'string'
171+
&& cmd.startsWith('--')
172+
&& (COMMAND_VALUES.includes(cmd) || registeredCommands.has(cmd));
173+
}
174+
175+
117176
/**
118177
* Adds a `command` listener to the target element
119178
*
@@ -133,7 +192,7 @@ const observer = typeof globalThis.document === 'undefined' ? null : new Mutatio
133192
if (mutation.type === 'attributes' && mutation.attributeName === 'commandfor') {
134193
const target = mutation.target.commandForElement;
135194

136-
if (target instanceof Element) {
195+
if (target instanceof Element && hasCommand(mutation.target.command)) {
137196
target.addEventListener('command', handleCommand);
138197
}
139198
}
@@ -143,14 +202,14 @@ const observer = typeof globalThis.document === 'undefined' ? null : new Mutatio
143202
if (node.nodeType === Node.ELEMENT_NODE && node.hasAttribute('commandfor')) {
144203
const el = node.commandForElement;
145204

146-
if (el instanceof Element) {
205+
if (el instanceof Element && hasCommand(node.command)) {
147206
el.addEventListener('command', handleCommand);
148207
}
149208
} else if (node.nodeType == Node.ELEMENT_NODE) {
150-
node.querySelectorAll('[commandfor]').forEach(el => {
209+
node.querySelectorAll('[commandfor][command]').forEach(el => {
151210
const target = el.commandForElement;
152211

153-
if (target instanceof Element) {
212+
if (target instanceof Element && hasCommand(el.command)) {
154213
target.addEventListener('command', handleCommand);
155214
}
156215
});
@@ -166,10 +225,10 @@ const observer = typeof globalThis.document === 'undefined' ? null : new Mutatio
166225
* @param {Element|DocumentFragment} target The root for the observer to watch from
167226
*/
168227
export function observeCommands(target = document.body) {
169-
target.querySelectorAll('button[commandfor]').forEach(el => {
228+
target.querySelectorAll('button[command][commandfor]').forEach(el => {
170229
const target = el.commandForElement;
171230

172-
if (target instanceof Element) {
231+
if (target instanceof Element && hasCommand(el.command)) {
173232
target.addEventListener('command', handleCommand);
174233
}
175234
});
@@ -225,15 +284,23 @@ export function createCommand(callback) {
225284
* @param {(event: CommandEvent) => void} callback The callback to call
226285
* @returns {string} `"command="..."`
227286
*/
228-
export function command(callback) {
229-
return `command="${createCommand(callback)}"`;
287+
export function command(callback, ...args) {
288+
return args.length === 0 ? `command="${createCommand(callback)}"` : `command="${createCommand(callback)}:${args.join(':')}"`;
230289
}
231290

232291
/**
233292
* Closes registration of new commands
234293
*
235294
* @returns {boolean}
236295
*/
237-
export const closeCommandRegistration = () => registrationOpen = false;
296+
export function closeCommandRegistration() {
297+
if (registrationOpen) {
298+
registrationOpen = false;
299+
return true;
300+
} else {
301+
return false;
302+
}
303+
}
238304

239-
export { COMMANDS };
305+
export { COMMANDS, ROOT_COMMANDS, getCommandWithArgs };
306+
export { registerRootCommand, closeCommandRootRegistry, handleRootCommand, initRootCommands } from './root-commands.js';

consts.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export const COMMANDS = {
1414
togglePopover: 'toggle-popover',
1515

1616
// Custom commands
17+
addClass: PREFIX + 'add-class',
18+
removeClass: PREFIX + 'remove-class',
1719
show: PREFIX + 'show',
1820
hide: PREFIX + 'hide',
1921
unhide: PREFIX + 'unhide',
@@ -35,3 +37,12 @@ export const COMMANDS = {
3537
requestPictureInPicture: PREFIX + 'request-picture-in-picture',
3638
copyText: PREFIX + 'copy-text',
3739
};
40+
41+
export const ROOT_COMMANDS = {
42+
print: PREFIX + 'root-print',
43+
share: PREFIX + 'root-share',
44+
back: PREFIX + 'root-back',
45+
forward: PREFIX + 'root-forward',
46+
reload: PREFIX + 'root-reload',
47+
exitFullscreen: PREFIX + 'root-exit-fullscreen',
48+
};

home.js

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,46 @@
1-
import { COMMANDS } from './consts.js';
1+
import { COMMANDS, ROOT_COMMANDS } from './consts.js';
2+
import { getCommandWithArgs } from './utils.js';
23

3-
const script = `import('/commands.js')
4-
.then(({ observeCommands }) => observeCommands());`;
4+
const script = `import { observeCommands, initRootCommands, registerRootCommand, registerCommand } from '/commands.js'
5+
registerRootCommand('--menu', () => document.getElementById('menu').showPopover());
6+
registerRootCommand('--help', () => document.getElementById('help').showPopover());
7+
registerRootCommand('--log', (event, ...args) => console.log({ event, args }));
8+
observeCommands();
9+
initRootCommands();
10+
`;
511

6-
const hash = await crypto.subtle.digest('SHA-384', new TextEncoder().encode(script))
12+
const style = `:root {
13+
color-scheme: dark;
14+
15+
& body.foo {
16+
filter: invert(1);
17+
}
18+
}`;
19+
20+
const sri = async text => await crypto.subtle.digest('SHA-384', new TextEncoder().encode(text))
721
.then(digest => new Uint8Array(digest).toBase64())
822
.then(hash => `sha384-${hash}`);
923

24+
const scriptSRI = await sri(script);
25+
const styleSRI = await sri(style);
26+
1027
const headers = new Headers({
1128
'Content-Type': 'text/html',
12-
'Content-Security-Policy': `default-src 'self'; script-src 'self' '${hash}'; media-src https://0eff4f4c-7f45-405c-8cf6-f7a3b3c1f07e.mdnplay.dev;`,
29+
'Content-Security-Policy': `default-src 'self'; script-src 'self' '${scriptSRI}'; style-src '${styleSRI}'; media-src https://0eff4f4c-7f45-405c-8cf6-f7a3b3c1f07e.mdnplay.dev;`,
1330
});
1431

1532

1633
const doc = `<!DOCTYPE html>
17-
<html>
34+
<html id="doc">
1835
<head>
1936
<meta charset="UTF-8" />
2037
<meta name="viewport" content="width=device-width" />
2138
<meta name="color-scheme" content="light dark" />
2239
<script src="/node_modules/@shgysk8zer0/polyfills/browser.min.js" referrerpolicy="no-referrer" defer=""></script>
23-
<script type="module" integrity="${hash}">${script}</script>
40+
<script type="module" integrity="${scriptSRI}">${script}</script>
41+
<style integrity="${styleSRI}" id="style">${style}</style>
2442
</head>
25-
<body>
43+
<body id="top">
2644
<header id="header"></header>
2745
<nav id="nav">
2846
<button type="button" command="${COMMANDS.disable}" commandfor="btn">Disable</button>
@@ -31,6 +49,14 @@ const doc = `<!DOCTYPE html>
3149
<button type="button" command="${COMMANDS.showModal}" commandfor="dialog">Show Modal Dialog</button>
3250
<button type="button" command="${COMMANDS.showPopover}" commandfor="popover">Show Popover</button>
3351
<button type="button" command="${COMMANDS.togglePopover}" commandfor="popover">Toggle Popover</button>
52+
<button type="button" command="${ROOT_COMMANDS.help}" commandfor="doc" accesskey="h">Help</button>
53+
<button type="button" command="${COMMANDS.disable}" commandfor="style">Disable Styles</button>
54+
<button type="button" command="${COMMANDS.enable}" commandfor="style">Enable Styles</button>
55+
<button type="button" command="${getCommandWithArgs(COMMANDS.addClass, 'foo', 'bar')}:foo:bar" commandfor="top">Add <code>foo</code> <code>bar</code></button>
56+
<button type="button" command="${getCommandWithArgs(COMMANDS.removeClass, 'foo', 'bar')}" commandfor="top">Remove <code>foo</code> <code>bar</code></button>
57+
<button type="button" command="--menu" commandfor="doc" accesskey="m">Menu</button>
58+
<button type="button" command="${COMMANDS.requestFullscreen}" commandfor="nav">Fullscreen</button>
59+
<button type="button" command="${ROOT_COMMANDS.exitFullscreen}" commandfor="doc">Exit Fullscreen</button>
3460
</nav>
3561
<main id="main"></main>
3662
<aside id="sidebar"></aside>
@@ -54,6 +80,15 @@ const doc = `<!DOCTYPE html>
5480
<p>This is a test of the <code>popover</code> API.</p>
5581
<button type="button" command="${COMMANDS.hidePopover}" commandfor="popover">Hide</button>
5682
</div>
83+
<menu id="menu" popover="auto">${[...Object.values(ROOT_COMMANDS), '--help'].map(command => `
84+
<li><button type="button" command="${command}" commandfor="doc"><code>${command}</code></button></li>
85+
`).join('')}
86+
<li><button type="button" command="--log:foo:bar:abc:123" commandfor="doc">Log</button></li>
87+
</menu>
88+
<div id="help" popover="auto">
89+
<p>Lorem Ipsum</p>
90+
<button type="button" command="hide-popover" commandfor="help">Close</button>
91+
</div>
5792
</body>
5893
</html>`;
5994

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/commands",
3-
"version": "1.0.0",
3+
"version": "1.0.1",
44
"description": "A lightweight command system for handling DOM interactions through custom events and the `commandfor` attribute.",
55
"keywords": [
66
"aegisjsproject",

0 commit comments

Comments
 (0)