id-dom 0.0.2 → 0.0.4

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 ADDED
@@ -0,0 +1,296 @@
1
+ # id-dom
2
+
3
+ [![npm version](https://img.shields.io/npm/v/id-dom.svg)](https://www.npmjs.com/package/id-dom)
4
+ [![npm downloads](https://img.shields.io/npm/dm/id-dom.svg)](https://www.npmjs.com/package/id-dom)
5
+ [![GitHub stars](https://img.shields.io/github/stars/iWhatty/id-dom.svg?style=social)](https://github.com/iWhatty/id-dom)
6
+ [![License](https://img.shields.io/github/license/iWhatty/id-dom.svg)](https://github.com/iWhatty/id-dom/blob/main/LICENSE)
7
+
8
+ **Deterministic DOM element getters by ID — typed, tiny, modern.**
9
+
10
+ `id-dom` is a small utility for grabbing DOM references safely **by `id`**, with predictable behavior:
11
+
12
+ * **Typed getters** like `button('saveBtn')`, `input('nameInput')`, `svg('icon')`
13
+ * **Strict or optional** mode (`throw` vs `null`)
14
+ * **Short optional alias** via `.opt`
15
+ * **Scoped lookups** for `document`, `ShadowRoot`, `DocumentFragment`, or an `Element`
16
+ * **Centralized error handling** with `onError` and optional `warn`
17
+ * **Zero deps**
18
+
19
+ This is deliberately **not** a selector framework. It is a tiny, ID-first primitive for safe DOM wiring.
20
+
21
+ ---
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ npm install id-dom
27
+ ```
28
+
29
+ ---
30
+
31
+ ## Quick Start
32
+
33
+ ```js
34
+ import dom from 'id-dom'
35
+
36
+ const saveBtn = dom.button('saveBtn')
37
+ saveBtn.addEventListener('click', save)
38
+ ```
39
+
40
+ Optional access never throws for missing or wrong-type elements:
41
+
42
+ ```js
43
+ const debug = dom.div.optional('debugPanel')
44
+ debug?.append('hello')
45
+
46
+ const maybeCanvas = dom.canvas.opt('game')
47
+ ```
48
+
49
+ ---
50
+
51
+ ## Why ID-first?
52
+
53
+ Using `getElementById` is:
54
+
55
+ * fast
56
+ * unambiguous
57
+ * easy to reason about
58
+
59
+ And with typed getters, you immediately know whether you got a `HTMLButtonElement`, `HTMLInputElement`, `SVGSVGElement`, and so on.
60
+
61
+ When scoped roots do not support `getElementById`, `id-dom` falls back to `querySelector(#id)` and safely escapes edge-case IDs.
62
+
63
+ ---
64
+
65
+ ## API
66
+
67
+ ### Default export: `dom`
68
+
69
+ The default export is a scoped instance using `document` (when available) with **strict** behavior:
70
+
71
+ * missing element → **throws**
72
+ * wrong type or wrong tag → **throws**
73
+ * invalid input → **throws**
74
+
75
+ ```js
76
+ import dom from 'id-dom'
77
+
78
+ const name = dom.input('nameInput')
79
+ const submit = dom.button('submitBtn')
80
+ ```
81
+
82
+ ---
83
+
84
+ ### `createDom(root, config?)`
85
+
86
+ Create a scoped instance that searches within a specific root:
87
+
88
+ * `document` → uses `getElementById`
89
+ * `ShadowRoot`, `DocumentFragment`, or `Element` → uses `querySelector(#id)` fallback
90
+
91
+ ```js
92
+ import { createDom } from 'id-dom'
93
+
94
+ const d = createDom(document, { mode: 'null', warn: true })
95
+ const sidebar = d.div('sidebar')
96
+ ```
97
+
98
+ #### Config
99
+
100
+ ```ts
101
+ type DomMode = 'throw' | 'null'
102
+
103
+ {
104
+ mode?: DomMode
105
+ warn?: boolean
106
+ onError?: (err: Error, ctx: any) => void
107
+ }
108
+ ```
109
+
110
+ ---
111
+
112
+ ### `byId(id, Type, config?)`
113
+
114
+ Generic typed lookup:
115
+
116
+ ```js
117
+ import { byId } from 'id-dom'
118
+
119
+ const btn = byId('saveBtn', HTMLButtonElement)
120
+ ```
121
+
122
+ Optional variants:
123
+
124
+ ```js
125
+ const maybeBtn = byId.optional('saveBtn', HTMLButtonElement)
126
+ const maybeBtn2 = byId.opt('saveBtn', HTMLButtonElement)
127
+ ```
128
+
129
+ #### Behavior
130
+
131
+ * valid match → returns the element
132
+ * missing element → throws or returns `null`
133
+ * wrong type → throws or returns `null`
134
+ * invalid `id` → throws or returns `null`
135
+ * invalid `Type` → throws or returns `null`
136
+
137
+ ---
138
+
139
+ ### `tag(id, tagName, config?)`
140
+
141
+ Tag-based validation when constructor checks are not the right fit:
142
+
143
+ ```js
144
+ import { tag } from 'id-dom'
145
+
146
+ const main = tag('appMain', 'main')
147
+ const icon = tag('icon', 'svg', { root: container })
148
+ ```
149
+
150
+ Optional variants:
151
+
152
+ ```js
153
+ const maybeMain = tag.optional('appMain', 'main')
154
+ const maybeMain2 = tag.opt('appMain', 'main')
155
+ ```
156
+
157
+ #### Behavior
158
+
159
+ * valid tag match → returns the element
160
+ * missing element → throws or returns `null`
161
+ * wrong tag → throws or returns `null`
162
+ * invalid `id` → throws or returns `null`
163
+ * invalid `tagName` → throws or returns `null`
164
+
165
+ ---
166
+
167
+ ## Built-in Getters
168
+
169
+ ### Typed getters
170
+
171
+ Available on `dom` and on any `createDom()` instance:
172
+
173
+ * `el(id)` → `HTMLElement`
174
+ * `input(id)` → `HTMLInputElement`
175
+ * `button(id)` → `HTMLButtonElement`
176
+ * `textarea(id)` → `HTMLTextAreaElement`
177
+ * `select(id)` → `HTMLSelectElement`
178
+ * `form(id)` → `HTMLFormElement`
179
+ * `div(id)` → `HTMLDivElement`
180
+ * `span(id)` → `HTMLSpanElement`
181
+ * `label(id)` → `HTMLLabelElement`
182
+ * `canvas(id)` → `HTMLCanvasElement`
183
+ * `template(id)` → `HTMLTemplateElement`
184
+ * `svg(id)` → `SVGSVGElement`
185
+ * `body(id)` → `HTMLBodyElement`
186
+
187
+ Each getter also has:
188
+
189
+ ```js
190
+ dom.canvas.optional('game')
191
+ dom.canvas.opt('game')
192
+ ```
193
+
194
+ ### Common tag helpers
195
+
196
+ * `main(id)` → validates `<main>`
197
+ * `section(id)` → validates `<section>`
198
+ * `small(id)` → validates `<small>`
199
+
200
+ Each also supports `.optional` and `.opt`.
201
+
202
+ ---
203
+
204
+ ## Error Handling
205
+
206
+ ### Throwing mode
207
+
208
+ ```js
209
+ import dom from 'id-dom'
210
+
211
+ dom.button('missing') // throws
212
+ ```
213
+
214
+ ### Null-returning mode
215
+
216
+ ```js
217
+ import { createDom } from 'id-dom'
218
+
219
+ const d = createDom(document, { mode: 'null' })
220
+ d.button('missing') // null
221
+ ```
222
+
223
+ ### Central reporting
224
+
225
+ ```js
226
+ const d = createDom(document, {
227
+ mode: 'null',
228
+ onError: (err, ctx) => {
229
+ // sendToSentry({ err, ctx })
230
+ },
231
+ })
232
+ ```
233
+
234
+ Enable console warnings too:
235
+
236
+ ```js
237
+ createDom(document, { mode: 'null', warn: true })
238
+ ```
239
+
240
+ ---
241
+
242
+ ## Scoped Roots
243
+
244
+ ### Shadow DOM
245
+
246
+ ```js
247
+ import { createDom } from 'id-dom'
248
+
249
+ const host = document.querySelector('#widget')
250
+ const shadow = host.attachShadow({ mode: 'open' })
251
+ shadow.innerHTML = `<button id="shadowBtn">Click</button>`
252
+
253
+ const d = createDom(shadow)
254
+ const btn = d.button('shadowBtn')
255
+ ```
256
+
257
+ ### Element root
258
+
259
+ ```js
260
+ const container = document.querySelector('#settings-panel')
261
+ const d = createDom(container)
262
+ const input = d.input('emailInput')
263
+ ```
264
+
265
+ ### SVG in scoped roots
266
+
267
+ ```js
268
+ const container = document.querySelector('#icons')
269
+ const d = createDom(container)
270
+ const icon = d.svg('logoMark')
271
+ ```
272
+
273
+ ---
274
+
275
+ ## Notes
276
+
277
+ * `el(id)` is specifically for `HTMLElement`, not every possible DOM `Element`.
278
+ * `body(id)` looks up a `<body>` **by ID**. This library stays ID-first on purpose.
279
+ * `tag()` can validate non-HTML tags too, such as `svg`, when used against supported scoped roots.
280
+
281
+ ---
282
+
283
+ ## Browser Support
284
+
285
+ Modern browsers supporting:
286
+
287
+ * `getElementById`
288
+ * `querySelector`
289
+
290
+ `CSS.escape` is used when available. A safe internal fallback is included for environments such as some jsdom builds where it may be missing.
291
+
292
+ ---
293
+
294
+ ## License
295
+
296
+ See `LICENSE`.
package/dist/index.cjs CHANGED
@@ -27,10 +27,22 @@ const DEFAULT_ROOT = typeof document !== "undefined" && document ? document : (
27
27
  /** @type {any} */
28
28
  null
29
29
  );
30
+ const REASON = (
31
+ /** @type {const} */
32
+ {
33
+ INVALID_ID: "invalid-id",
34
+ INVALID_TYPE: "invalid-type",
35
+ INVALID_TAG: "invalid-tag",
36
+ MISSING: "missing",
37
+ WRONG_TYPE: "wrong-type",
38
+ WRONG_TAG: "wrong-tag"
39
+ }
40
+ );
41
+ const SAFE_ID_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
42
+ const NEEDS_START_ESCAPE_RE = /^(?:\d|-\d)/;
30
43
  function normalizeConfig(cfg) {
31
44
  return {
32
45
  mode: cfg?.mode ?? "throw",
33
- // default strict
34
46
  warn: cfg?.warn ?? false,
35
47
  onError: typeof cfg?.onError === "function" ? cfg.onError : null,
36
48
  root: cfg?.root ?? DEFAULT_ROOT
@@ -42,8 +54,6 @@ function hasGetElementById(v) {
42
54
  function hasQuerySelector(v) {
43
55
  return !!v && typeof v === "object" && typeof v.querySelector === "function";
44
56
  }
45
- const SAFE_ID_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
46
- const NEEDS_START_ESCAPE_RE = /^(?:\d|-\d)/;
47
57
  function cssEscape(id) {
48
58
  const s = String(id);
49
59
  if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
@@ -59,10 +69,13 @@ function cssEscape(id) {
59
69
  cp >= 97 && cp <= 122 || // a-z
60
70
  cp === 95 || // _
61
71
  cp === 45;
62
- const needsStartEscape = i === 0 && (cp >= 48 && cp <= 57 || cp === 45 && s.length > 1 && s.codePointAt(1) >= 48 && s.codePointAt(1) <= 57);
72
+ const next = s.codePointAt(i + 1);
73
+ const startsWithDigit = cp >= 48 && cp <= 57;
74
+ const startsWithDashDigit = cp === 45 && s.length > 1 && next >= 48 && next <= 57;
75
+ const needsStartEscape = i === 0 && (startsWithDigit || startsWithDashDigit);
63
76
  if (!needsStartEscape && (isAsciiSafe || cp >= 160)) {
64
77
  out += ch;
65
- } else if (cp === 45 && i === 0 && s.length > 1 && s.codePointAt(1) >= 48 && s.codePointAt(1) <= 57) {
78
+ } else if (i === 0 && startsWithDashDigit) {
66
79
  out += "\\-";
67
80
  } else {
68
81
  out += `\\${cp.toString(16).toUpperCase()} `;
@@ -71,16 +84,32 @@ function cssEscape(id) {
71
84
  }
72
85
  return out;
73
86
  }
87
+ function isElementNode(v) {
88
+ if (!v || typeof v !== "object") return false;
89
+ if (typeof Element !== "undefined") return v instanceof Element;
90
+ return (
91
+ /** @type {any} */
92
+ v.nodeType === 1
93
+ );
94
+ }
74
95
  function getById(root, id) {
75
96
  if (!root) return null;
76
97
  if (hasGetElementById(root)) return root.getElementById(id);
77
98
  if (hasQuerySelector(root)) {
78
- const sel = `#${cssEscape(id)}`;
79
- const el = root.querySelector(sel);
80
- return el instanceof HTMLElement ? el : null;
99
+ const el = root.querySelector(`#${cssEscape(id)}`);
100
+ return isElementNode(el) ? el : null;
81
101
  }
82
102
  return null;
83
103
  }
104
+ function isValidId(v) {
105
+ return typeof v === "string" && v.length > 0;
106
+ }
107
+ function isValidTagName(v) {
108
+ return typeof v === "string" && v.trim().length > 0;
109
+ }
110
+ function isConstructor(v) {
111
+ return typeof v === "function";
112
+ }
84
113
  function fmtId(id) {
85
114
  return id.startsWith("#") ? id : `#${id}`;
86
115
  }
@@ -95,54 +124,114 @@ function handleLookupError(err, ctx, cfg) {
95
124
  cfg.onError?.(err, ctx);
96
125
  } catch {
97
126
  }
98
- if (cfg.warn) console.warn(err);
127
+ if (cfg.warn) console.warn(err, ctx);
99
128
  if (cfg.mode === "throw") throw err;
100
129
  return null;
101
130
  }
102
- function byId(id, Type, config) {
131
+ function createCtx(id, root, reason, extra) {
132
+ return {
133
+ id,
134
+ root,
135
+ reason,
136
+ ...extra || {}
137
+ };
138
+ }
139
+ function resolveLookup(config, spec) {
103
140
  const cfg = normalizeConfig(config);
104
- const el = getById(cfg.root, id);
141
+ const inputFailure = spec.validateInput(cfg);
142
+ if (inputFailure) {
143
+ return handleLookupError(inputFailure.err, inputFailure.ctx, cfg);
144
+ }
145
+ const el = getById(cfg.root, spec.id);
105
146
  if (!el) {
106
- return handleLookupError(
107
- missingElError(id, Type.name),
108
- { id, Type, root: cfg.root, reason: "missing" },
109
- cfg
110
- );
111
- }
112
- if (!(el instanceof Type)) {
113
- const got = el?.constructor?.name || typeof el;
114
- return handleLookupError(
115
- wrongTypeError(id, Type.name, got),
116
- { id, Type, root: cfg.root, reason: "wrong-type", got },
117
- cfg
118
- );
119
- }
120
- return el;
147
+ const failure = spec.onMissing(cfg);
148
+ return handleLookupError(failure.err, failure.ctx, cfg);
149
+ }
150
+ if (!spec.matches(el, cfg)) {
151
+ const failure = spec.onMismatch(el, cfg);
152
+ return handleLookupError(failure.err, failure.ctx, cfg);
153
+ }
154
+ return (
155
+ /** @type {T} */
156
+ el
157
+ );
158
+ }
159
+ function byId(id, Type, config) {
160
+ return resolveLookup(config, {
161
+ id,
162
+ validateInput(cfg) {
163
+ if (!isValidId(id)) {
164
+ return {
165
+ err: new Error("id-dom: invalid id (expected non-empty string)"),
166
+ ctx: createCtx(String(id), cfg.root, REASON.INVALID_ID, { Type })
167
+ };
168
+ }
169
+ if (!isConstructor(Type)) {
170
+ return {
171
+ err: new Error(`id-dom: invalid Type for ${fmtId(id)}`),
172
+ ctx: createCtx(id, cfg.root, REASON.INVALID_TYPE, { Type })
173
+ };
174
+ }
175
+ return null;
176
+ },
177
+ onMissing(cfg) {
178
+ return {
179
+ err: missingElError(id, Type.name),
180
+ ctx: createCtx(id, cfg.root, REASON.MISSING, { Type })
181
+ };
182
+ },
183
+ matches(el) {
184
+ return el instanceof Type;
185
+ },
186
+ onMismatch(el, cfg) {
187
+ const got = el?.constructor?.name || typeof el;
188
+ return {
189
+ err: wrongTypeError(id, Type.name, got),
190
+ ctx: createCtx(id, cfg.root, REASON.WRONG_TYPE, { Type, got })
191
+ };
192
+ }
193
+ });
121
194
  }
122
195
  byId.optional = function byIdOptional(id, Type, config) {
123
196
  return byId(id, Type, { ...config, mode: "null" });
124
197
  };
125
198
  byId.opt = byId.optional;
126
199
  function tag(id, tagName, config) {
127
- const cfg = normalizeConfig(config);
128
- const el = getById(cfg.root, id);
129
- if (!el) {
130
- return handleLookupError(
131
- missingElError(id, `<${tagName}>`),
132
- { id, tagName, root: cfg.root, reason: "missing" },
133
- cfg
134
- );
135
- }
136
- const expected = String(tagName).toUpperCase();
137
- const got = String(el.tagName || "").toUpperCase();
138
- if (got !== expected) {
139
- return handleLookupError(
140
- wrongTypeError(id, `<${expected.toLowerCase()}>`, `<${got.toLowerCase()}>`),
141
- { id, tagName, root: cfg.root, reason: "wrong-tag", got },
142
- cfg
143
- );
144
- }
145
- return el;
200
+ return resolveLookup(config, {
201
+ id,
202
+ validateInput(cfg) {
203
+ if (!isValidId(id)) {
204
+ return {
205
+ err: new Error("id-dom: invalid id (expected non-empty string)"),
206
+ ctx: createCtx(String(id), cfg.root, REASON.INVALID_ID, { tagName })
207
+ };
208
+ }
209
+ if (!isValidTagName(tagName)) {
210
+ return {
211
+ err: new Error(`id-dom: invalid tagName for ${fmtId(id)}`),
212
+ ctx: createCtx(id, cfg.root, REASON.INVALID_TAG, { tagName })
213
+ };
214
+ }
215
+ return null;
216
+ },
217
+ onMissing(cfg) {
218
+ return {
219
+ err: missingElError(id, `<${tagName}>`),
220
+ ctx: createCtx(id, cfg.root, REASON.MISSING, { tagName })
221
+ };
222
+ },
223
+ matches(el) {
224
+ return String(el.tagName || "").toUpperCase() === String(tagName).toUpperCase();
225
+ },
226
+ onMismatch(el, cfg) {
227
+ const expected = String(tagName).toUpperCase();
228
+ const got = String(el.tagName || "").toUpperCase();
229
+ return {
230
+ err: wrongTypeError(id, `<${expected.toLowerCase()}>`, `<${got.toLowerCase()}>`),
231
+ ctx: createCtx(id, cfg.root, REASON.WRONG_TAG, { tagName, got })
232
+ };
233
+ }
234
+ });
146
235
  }
147
236
  tag.optional = function tagOptional(id, tagName, config) {
148
237
  return tag(id, tagName, { ...config, mode: "null" });
@@ -162,7 +251,8 @@ const TYPE_HELPERS = (
162
251
  label: typeof HTMLLabelElement !== "undefined" ? HTMLLabelElement : null,
163
252
  canvas: typeof HTMLCanvasElement !== "undefined" ? HTMLCanvasElement : null,
164
253
  template: typeof HTMLTemplateElement !== "undefined" ? HTMLTemplateElement : null,
165
- svg: typeof SVGSVGElement !== "undefined" ? SVGSVGElement : null
254
+ svg: typeof SVGSVGElement !== "undefined" ? SVGSVGElement : null,
255
+ body: typeof HTMLBodyElement !== "undefined" ? HTMLBodyElement : null
166
256
  }
167
257
  );
168
258
  const TAG_HELPERS = (
@@ -173,42 +263,53 @@ const TAG_HELPERS = (
173
263
  small: "small"
174
264
  }
175
265
  );
266
+ function attachOptional(fn, optionalFn) {
267
+ if (typeof fn !== "function") {
268
+ throw new TypeError("id-dom: attachOptional expected fn to be a function");
269
+ }
270
+ if (typeof optionalFn !== "function") {
271
+ throw new TypeError("id-dom: attachOptional expected optionalFn to be a function");
272
+ }
273
+ fn.optional = optionalFn;
274
+ fn.opt = optionalFn;
275
+ return fn;
276
+ }
277
+ function makeTypedHelper(Type, base, baseNull) {
278
+ if (!Type) {
279
+ throw new TypeError("id-dom: makeTypedHelper received an invalid Type");
280
+ }
281
+ return attachOptional(
282
+ (id) => byId(id, Type, base),
283
+ (id) => byId(id, Type, baseNull)
284
+ );
285
+ }
286
+ function makeTagHelper(tagName, base, baseNull) {
287
+ if (!tagName) {
288
+ throw new TypeError("id-dom: makeTagHelper received an invalid tagName");
289
+ }
290
+ return attachOptional(
291
+ (id) => tag(id, tagName, base),
292
+ (id) => tag(id, tagName, baseNull)
293
+ );
294
+ }
176
295
  function createDom(root, config) {
177
296
  const base = normalizeConfig({ ...config, root });
178
297
  const baseNull = { ...base, mode: "null" };
179
- const api = {
180
- // generic
181
- byId: (id, Type) => byId(id, Type, base),
182
- tag: (id, name) => tag(id, name, base)
183
- };
298
+ const api = {};
299
+ api.byId = attachOptional(
300
+ (id, Type) => byId(id, Type, base),
301
+ (id, Type) => byId(id, Type, baseNull)
302
+ );
303
+ api.tag = attachOptional(
304
+ (id, name) => tag(id, name, base),
305
+ (id, name) => tag(id, name, baseNull)
306
+ );
184
307
  for (const [name, Type] of Object.entries(TYPE_HELPERS)) {
185
308
  if (!Type) continue;
186
- api[name] = (id) => byId(id, Type, base);
309
+ api[name] = makeTypedHelper(Type, base, baseNull);
187
310
  }
188
311
  for (const [name, tagName] of Object.entries(TAG_HELPERS)) {
189
- api[name] = (id) => tag(id, tagName, base);
190
- }
191
- api.byId.optional = (id, Type) => byId(id, Type, baseNull);
192
- api.byId.opt = api.byId.optional;
193
- api.tag.optional = (id, name) => tag(id, name, baseNull);
194
- api.tag.opt = api.tag.optional;
195
- for (const k of Object.keys(api)) {
196
- const fn = api[k];
197
- if (typeof fn !== "function") continue;
198
- if (fn.optional) continue;
199
- if (k in TAG_HELPERS) {
200
- const tagName = TAG_HELPERS[k];
201
- fn.optional = (id) => tag(id, tagName, baseNull);
202
- fn.opt = fn.optional;
203
- continue;
204
- }
205
- if (k in TYPE_HELPERS) {
206
- const Type = TYPE_HELPERS[k];
207
- if (Type) {
208
- fn.optional = (id) => byId(id, Type, baseNull);
209
- fn.opt = fn.optional;
210
- }
211
- }
312
+ api[name] = makeTagHelper(tagName, base, baseNull);
212
313
  }
213
314
  return api;
214
315
  }