113 | The event handler is bound to wrapper div (.delegation-example), but the handler is
114 | only triggered when the event targeted to '.btn' element.
115 |
138 | When you're creating floating UI patterns such as tooltips or modal dialogs, you often need to handle the events "outside" of the target dom.
139 | Capsule supports this pattern with "on.outside[eventName]".
140 |
const { on } = component("prevent-example");
166 |
167 | on.click = ({ e }) => {
168 | e.preventDefault();
169 | alert("A link is clicked, but the page doesn't move to the target url because the default behavior is prevented ;)");
170 | };
171 |
HTML
172 |
173 |
Result
174 |
175 |
176 |
177 | More details about capsule library is available in
178 | the github repository.
179 |
180 |
181 |
185 |
188 |
189 |
194 |
210 |
--------------------------------------------------------------------------------
/loader.js:
--------------------------------------------------------------------------------
1 | globalThis.capsuleLoader = import("./dist.min.js");
2 |
--------------------------------------------------------------------------------
/mod.ts:
--------------------------------------------------------------------------------
1 | /*! Capsule v0.6.1 | Copyright 2022 Yoshiya Hinosawa and Capsule contributors | MIT license */
2 | import { documentReady, logEvent } from "./util.ts";
3 |
4 | interface Initializer {
5 | (el: HTMLElement): void;
6 | /** The elector for the component */
7 | sel: string;
8 | }
9 | interface RegistryType {
10 | [key: string]: Initializer;
11 | }
12 | interface EventRegistry {
13 | outside: {
14 | [key: string]: EventHandler;
15 | };
16 | // deno-lint-ignore ban-types
17 | [key: string]: EventHandler | {};
18 | (selector: string): {
19 | [key: string]: EventHandler;
20 | };
21 | }
22 | interface ComponentResult {
23 | on: EventRegistry;
24 | is(name: string): void;
25 | sub(type: string): void;
26 | innerHTML(html: string): void;
27 | }
28 |
29 | interface ComponentEventContext {
30 | /** The event */
31 | e: Event;
32 | /** The element */
33 | el: HTMLElement;
34 | /** Queries elements by the given selector under the component dom */
35 | query(selector: string): T | null;
36 | /** Queries all elements by the given selector under the component dom */
37 | queryAll(
38 | selector: string,
39 | ): NodeListOf;
40 | /** Publishes the event. Events are delivered to elements which have `sub:event` class.
41 | * The dispatched events don't bubbles up */
42 | pub(name: string, data?: T): void;
43 | }
44 |
45 | type EventHandler = (el: ComponentEventContext) => void;
46 |
47 | /** The registry of component initializers. */
48 | const registry: RegistryType = {};
49 |
50 | /**
51 | * Asserts the given condition holds, otherwise throws.
52 | * @param assertion The assertion expression
53 | * @param message The assertion message
54 | */
55 | function assert(assertion: boolean, message: string): void {
56 | if (!assertion) {
57 | throw new Error(message);
58 | }
59 | }
60 |
61 | /** Asserts the given name is a valid component name.
62 | * @param name The component name */
63 | function assertComponentNameIsValid(name: unknown): void {
64 | assert(typeof name === "string", "The name should be a string");
65 | assert(
66 | !!registry[name as string],
67 | `The component of the given name is not registered: ${name}`,
68 | );
69 | }
70 |
71 | export function component(name: string): ComponentResult {
72 | assert(
73 | typeof name === "string" && !!name,
74 | "Component name must be a non-empty string",
75 | );
76 | assert(
77 | !registry[name],
78 | `The component of the given name is already registered: ${name}`,
79 | );
80 |
81 | const initClass = `${name}-💊`;
82 |
83 | // Hooks for mount phase
84 | const hooks: EventHandler[] = [({ el }) => {
85 | // FIXME(kt3k): the below can be written as .add(name, initClass)
86 | // when deno_dom fixes add class.
87 | el.classList.add(name);
88 | el.classList.add(initClass);
89 | el.addEventListener(`__ummount__:${name}`, () => {
90 | el.classList.remove(initClass);
91 | }, { once: true });
92 | }];
93 | const mountHooks: EventHandler[] = [];
94 |
95 | /** Initializes the html element by the given configuration. */
96 | const initializer = (el: HTMLElement) => {
97 | if (!el.classList.contains(initClass)) {
98 | const e = new CustomEvent("__mount__", { bubbles: false });
99 | const ctx = createEventContext(e, el);
100 | // Initialize `before mount` hooks
101 | // This includes:
102 | // - initialization of event handlers
103 | // - initialization of innerHTML
104 | // - initialization of class names (is, sub)
105 | hooks.map((cb) => {
106 | cb(ctx);
107 | });
108 | // Execute __mount__ hooks
109 | mountHooks.map((cb) => {
110 | cb(ctx);
111 | });
112 | }
113 | };
114 |
115 | // The selector
116 | initializer.sel = `.${name}:not(.${initClass})`;
117 |
118 | registry[name] = initializer;
119 |
120 | documentReady().then(() => {
121 | mount(name);
122 | });
123 |
124 | // deno-lint-ignore no-explicit-any
125 | const on: any = new Proxy(() => {}, {
126 | set(_: unknown, type: string, value: unknown): boolean {
127 | // deno-lint-ignore no-explicit-any
128 | return addEventBindHook(name, hooks, mountHooks, type, value as any);
129 | },
130 | get(_: unknown, outside: string) {
131 | if (outside === "outside") {
132 | return new Proxy({}, {
133 | set(_: unknown, type: string, value: unknown): boolean {
134 | assert(
135 | typeof value === "function",
136 | `Event handler must be a function, ${typeof value} (${value}) is given`,
137 | );
138 | hooks.push(({ el }) => {
139 | const listener = (e: Event) => {
140 | // deno-lint-ignore no-explicit-any
141 | if (el !== e.target && !el.contains(e.target as any)) {
142 | logEvent({
143 | module: "outside",
144 | color: "#39cccc",
145 | e,
146 | component: name,
147 | });
148 | (value as EventHandler)(createEventContext(e, el));
149 | }
150 | };
151 | document.addEventListener(type, listener);
152 | el.addEventListener(`__unmount__:${name}`, () => {
153 | document.removeEventListener(type, listener);
154 | }, { once: true });
155 | });
156 | return true;
157 | },
158 | });
159 | }
160 | return null;
161 | },
162 | apply(_target, _thisArg, args) {
163 | const selector = args[0];
164 | assert(
165 | typeof selector === "string",
166 | "Delegation selector must be a string. ${typeof selector} is given.",
167 | );
168 | return new Proxy({}, {
169 | set(_: unknown, type: string, value: unknown): boolean {
170 | return addEventBindHook(
171 | name,
172 | hooks,
173 | mountHooks,
174 | type,
175 | // deno-lint-ignore no-explicit-any
176 | value as any,
177 | selector,
178 | );
179 | },
180 | });
181 | },
182 | });
183 |
184 | const is = (name: string) => {
185 | hooks.push(({ el }) => {
186 | el.classList.add(name);
187 | });
188 | };
189 | const sub = (type: string) => is(`sub:${type}`);
190 | const innerHTML = (html: string) => {
191 | hooks.push(({ el }) => {
192 | el.innerHTML = html;
193 | });
194 | };
195 |
196 | return { on, is, sub, innerHTML };
197 | }
198 |
199 | function createEventContext(e: Event, el: HTMLElement): ComponentEventContext {
200 | return {
201 | e,
202 | el,
203 | query: (s: string) => el.querySelector(s),
204 | queryAll: (s: string) => el.querySelectorAll(s),
205 | pub: (type: string, data?: unknown) => {
206 | document.querySelectorAll(`.sub\\:${type}`).forEach((el) => {
207 | el.dispatchEvent(
208 | new CustomEvent(type, { bubbles: false, detail: data }),
209 | );
210 | });
211 | },
212 | };
213 | }
214 |
215 | function addEventBindHook(
216 | name: string,
217 | hooks: EventHandler[],
218 | mountHooks: EventHandler[],
219 | type: string,
220 | handler: (ctx: ComponentEventContext) => void,
221 | selector?: string,
222 | ): boolean {
223 | assert(
224 | typeof handler === "function",
225 | `Event handler must be a function, ${typeof handler} (${handler}) is given`,
226 | );
227 | if (type === "__mount__") {
228 | mountHooks.push(handler);
229 | return true;
230 | }
231 | if (type === "__unmount__") {
232 | hooks.push(({ el }) => {
233 | el.addEventListener(`__unmount__:${name}`, () => {
234 | handler(createEventContext(new CustomEvent("__unmount__"), el));
235 | }, { once: true });
236 | });
237 | return true;
238 | }
239 | hooks.push(({ el }) => {
240 | const listener = (e: Event) => {
241 | if (
242 | !selector ||
243 | [].some.call(
244 | el.querySelectorAll(selector),
245 | (node: Node) => node === e.target || node.contains(e.target as Node),
246 | )
247 | ) {
248 | logEvent({
249 | module: "💊",
250 | color: "#e0407b",
251 | e,
252 | component: name,
253 | });
254 | handler(createEventContext(e, el));
255 | }
256 | };
257 | el.addEventListener(`__unmount__:${name}`, () => {
258 | el.removeEventListener(type, listener);
259 | }, { once: true });
260 | el.addEventListener(type, listener);
261 | });
262 | return true;
263 | }
264 |
265 | export function mount(name?: string | null, el?: HTMLElement) {
266 | let classNames: string[];
267 |
268 | if (!name) {
269 | classNames = Object.keys(registry);
270 | } else {
271 | assertComponentNameIsValid(name);
272 |
273 | classNames = [name];
274 | }
275 |
276 | classNames.map((className) => {
277 | [].map.call(
278 | (el || document).querySelectorAll(registry[className].sel),
279 | registry[className],
280 | );
281 | });
282 | }
283 |
284 | export function unmount(name: string, el: HTMLElement) {
285 | assert(
286 | !!registry[name],
287 | `The component of the given name is not registered: ${name}`,
288 | );
289 | el.dispatchEvent(new CustomEvent(`__unmount__:${name}`));
290 | }
291 |
--------------------------------------------------------------------------------
/style.css:
--------------------------------------------------------------------------------
1 | button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;margin:0;padding:0;line-height:inherit;color:inherit}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"}table{text-indent:0;border-color:inherit;border-collapse:collapse}hr{height:0;color:inherit;border-top-width:1px}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}button{background-color:transparent;background-image:none}body{font-family:inherit;line-height:inherit}*,::before,::after{box-sizing:border-box;border:0 solid #e5e7eb}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}::-moz-focus-inner{border-style:none;padding:0}[type="search"]{-webkit-appearance:textfield;outline-offset:-2px}pre,code,kbd,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}body,blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre,fieldset,ol,ul{margin:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}fieldset,ol,ul,legend{padding:0}textarea{resize:vertical}button,[role="button"]{cursor:pointer}:-moz-focusring{outline:1px dotted ButtonText}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}summary{display:list-item}:root{-moz-tab-size:4;tab-size:4}ol,ul{list-style:none}img{border-style:solid}button,select{text-transform:none}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}sub{bottom:-0.25em}sup{top:-0.5em}button,[type="button"],[type="reset"],[type="submit"]{-webkit-appearance:button}::-webkit-search-decoration{-webkit-appearance:none}*{--tw-shadow:0 0 transparent}.text-gray-700{--tw-text-opacity:1;color:#374151;color:rgba(55,65,81,var(--tw-text-opacity))}.text-blue-800{--tw-text-opacity:1;color:#1e40af;color:rgba(30,64,175,var(--tw-text-opacity))}.text-gray-100{--tw-text-opacity:1;color:#f3f4f6;color:rgba(243,244,246,var(--tw-text-opacity))}.text-purple-800{--tw-text-opacity:1;color:#5b21b6;color:rgba(91,33,182,var(--tw-text-opacity))}.px-2{padding-left:0.5rem;padding-right:0.5rem}.text-2xl{font-size:1.5rem;line-height:2rem}.bg-blue-50{--tw-bg-opacity:1;background-color:#eff6ff;background-color:rgba(239,246,255,var(--tw-bg-opacity))}.px-1{padding-left:0.25rem;padding-right:0.25rem}.py-0{padding-bottom:0px;padding-top:0px}.text-lg{font-size:1.125rem;line-height:1.75rem}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);box-shadow:0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);box-shadow:var(--tw-ring-offset-shadow,0 0 transparent),var(--tw-ring-shadow,0 0 transparent),var(--tw-shadow)}.bg-gray-800{--tw-bg-opacity:1;background-color:#1f2937;background-color:rgba(31,41,55,var(--tw-bg-opacity))}.-mx-2{margin-left:calc(0.5rem * -1);margin-right:calc(0.5rem * -1)}.my-10{margin-bottom:2.5rem;margin-top:2.5rem}.bg-purple-50{--tw-bg-opacity:1;background-color:#f5f3ff;background-color:rgba(245,243,255,var(--tw-bg-opacity))}.my-3{margin-bottom:0.75rem;margin-top:0.75rem}.py-10{padding-bottom:2.5rem;padding-top:2.5rem}.m-auto{margin:auto}.flex{display:flex}.gap-2{grid-gap:0.5rem;gap:0.5rem}.w-7{width:1.75rem}.h-7{height:1.75rem}.p-5{padding:1.25rem}.p-2{padding:0.5rem}.m-2{margin:0.5rem}.max-w-screen-lg{max-width:1024px}.font-semibold{font-weight:600}.mt-20{margin-top:5rem}.items-center{align-items:center}.mt-4{margin-top:1rem}.underline{-webkit-text-decoration:underline;text-decoration:underline}.font-medium{font-weight:500}.border{border-width:1px}.mt-10{margin-top:2.5rem}.mt-1{margin-top:0.25rem}.overflow-x-scroll{overflow-x:scroll}.mt-5{margin-top:1.25rem}.justify-center{justify-content:center}.rounded{border-radius:0.25rem}.rounded-lg{border-radius:0.5rem}@media (min-width:1024px){.lg\:mx-2{margin-left:0.5rem;margin-right:0.5rem}}@media (min-width:1024px){.lg\:rounded-lg{border-radius:0.5rem}}
--------------------------------------------------------------------------------
/test.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 Yoshiya Hinosawa. All rights reserved. MIT license.
2 |
3 | import { assert, assertEquals, assertThrows } from "@std/assert";
4 | import "./dom_polyfill_deno.ts";
5 | import { component, mount, unmount } from "./mod.ts";
6 |
7 | // disable debug logs because it's too verbose for unit testing
8 | // deno-lint-ignore no-explicit-any
9 | (globalThis as any).__DEV__ = false;
10 |
11 | Deno.test("on.__mount__ is called when the component is mounted", () => {
12 | const name = randomName();
13 | const { on } = component(name);
14 |
15 | document.body.innerHTML = ``;
16 |
17 | let called = false;
18 |
19 | on.__mount__ = () => {
20 | called = true;
21 | };
22 |
23 | mount();
24 |
25 | assert(called);
26 | });
27 |
28 | Deno.test("on.__mount__ is called after other initialization is finished", () => {
29 | const name = randomName();
30 | const { on, is, sub, innerHTML } = component(name);
31 |
32 | document.body.innerHTML = ``;
33 |
34 | let hasFoo = false;
35 | let hasSubBar = false;
36 | let hasInnerHTML = false;
37 |
38 | on.__mount__ = ({ el }) => {
39 | hasFoo = el.classList.contains("foo");
40 | hasSubBar = el.classList.contains("sub:bar");
41 | hasInnerHTML = el.innerHTML === "