cotomy 0.3.13 → 0.3.15

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 CHANGED
@@ -87,6 +87,8 @@ The View layer provides thin wrappers around DOM elements and window events.
87
87
  - `innerWidth: number` / `innerHeight: number`
88
88
  - `outerWidth: number` / `outerHeight: number` — Includes margins
89
89
  - `scrollWidth: number` / `scrollHeight: number` / `scrollTop: number`
90
+ - `scrollIn(options?: CotomyScrollOptions | Partial<CotomyScrollOptions>): this` — Scrolls the nearest scrollable container (or window) to reveal the element
91
+ - `scrollTo(target, options?: CotomyScrollOptions | Partial<CotomyScrollOptions>): this` — Convenience wrapper; if `target` is a selector it searches descendants then calls `scrollIn()`
90
92
  - `position(): { top, left }` — Relative to viewport
91
93
  - `absolutePosition(): { top, left }` — Viewport + page scroll offset
92
94
  - `screenPosition(): { top, left }`
@@ -151,6 +153,7 @@ The first command ensures `[scope]` expands to `[data-cotomy-scopeid="..."]` in
151
153
  - DOM helpers
152
154
  - `body: CotomyElement`
153
155
  - `append(element: CotomyElement): this`
156
+ - `scrollTo(target, options?: CotomyScrollOptions | Partial<CotomyScrollOptions>): this` — Scrolls to reveal a target (`selector | CotomyElement | HTMLElement`)
154
157
  - `moveNext(focused: CotomyElement, shift = false)` — Move focus to next/previous focusable
155
158
  - Window events
156
159
  - `on(eventOrEvents, handler): this` / `off(eventOrEvents, handler?): this` / `trigger(event[, Event]): this` — `eventOrEvents` accepts a single event name or an array. CotomyWindow’s `trigger` also bubbles by default and accepts an `Event` to override the behavior.
@@ -243,6 +246,7 @@ The Form layer builds on `CotomyElement` for common form flows.
243
246
 
244
247
  - `mail`, `tel`, `url` — Wrap the value in a corresponding anchor tag.
245
248
  - `number` — Uses `Intl.NumberFormat` with `data-cotomy-locale`/`data-cotomy-currency` inheritance.
249
+ - `data-cotomy-fraction-digits="2"` — Forces fixed fraction digits (sets both `minimumFractionDigits` and `maximumFractionDigits`). Works with or without `data-cotomy-currency` (e.g. `0` → `0.00`).
246
250
  - `utc` — Treats the value as UTC (or appends `Z` when missing) and formats with `data-cotomy-format` (default `YYYY/MM/DD HH:mm`).
247
251
  - `date` — Renders local dates with `data-cotomy-format` (default `YYYY/MM/DD`) when the input is a valid `Date` value.
248
252
 
@@ -841,6 +841,30 @@ class EventRegistry {
841
841
  this._registry.delete(target.scopeId);
842
842
  }
843
843
  }
844
+ class CotomyScrollOptions {
845
+ constructor(init = {}) {
846
+ this.behavior = "smooth";
847
+ this.onlyIfNeeded = true;
848
+ this.block = "nearest";
849
+ this.inline = "nearest";
850
+ if (init.behavior !== undefined)
851
+ this.behavior = init.behavior;
852
+ if (init.onlyIfNeeded !== undefined)
853
+ this.onlyIfNeeded = init.onlyIfNeeded;
854
+ if (init.block !== undefined)
855
+ this.block = init.block;
856
+ if (init.inline !== undefined)
857
+ this.inline = init.inline;
858
+ }
859
+ resolveBehavior() {
860
+ return this.behavior;
861
+ }
862
+ static from(options) {
863
+ if (options instanceof CotomyScrollOptions)
864
+ return options;
865
+ return new CotomyScrollOptions(options ?? {});
866
+ }
867
+ }
844
868
  class CotomyElement {
845
869
  static encodeHtml(text) {
846
870
  const div = document.createElement("div");
@@ -1288,6 +1312,107 @@ class CotomyElement {
1288
1312
  get isRightViewport() {
1289
1313
  return this.element.getBoundingClientRect().left > window.innerWidth;
1290
1314
  }
1315
+ static isScrollableOverflow(value) {
1316
+ const overflow = (value ?? "").toLowerCase();
1317
+ return overflow === "auto" || overflow === "scroll";
1318
+ }
1319
+ static findNearestScrollableAncestor(element) {
1320
+ let current = element.parentElement;
1321
+ while (current) {
1322
+ if (current === document.body || current === document.documentElement) {
1323
+ return null;
1324
+ }
1325
+ const style = window.getComputedStyle(current);
1326
+ const overflowY = style.overflowY;
1327
+ const overflowX = style.overflowX;
1328
+ const canScrollY = CotomyElement.isScrollableOverflow(overflowY) && current.scrollHeight > current.clientHeight;
1329
+ const canScrollX = CotomyElement.isScrollableOverflow(overflowX) && current.scrollWidth > current.clientWidth;
1330
+ if (canScrollY || canScrollX) {
1331
+ return current;
1332
+ }
1333
+ current = current.parentElement;
1334
+ }
1335
+ return null;
1336
+ }
1337
+ static computeAlignedScroll(viewStart, viewSize, elementStart, elementEnd, align, onlyIfNeeded) {
1338
+ const viewEnd = viewStart + viewSize;
1339
+ if (onlyIfNeeded && elementStart >= viewStart && elementEnd <= viewEnd) {
1340
+ return null;
1341
+ }
1342
+ if (align === "start") {
1343
+ return elementStart;
1344
+ }
1345
+ if (align === "end") {
1346
+ return elementEnd - viewSize;
1347
+ }
1348
+ if (align === "center") {
1349
+ const elementSize = elementEnd - elementStart;
1350
+ return elementStart - (viewSize - elementSize) / 2;
1351
+ }
1352
+ if (elementStart < viewStart) {
1353
+ return elementStart;
1354
+ }
1355
+ if (elementEnd > viewEnd) {
1356
+ return elementEnd - viewSize;
1357
+ }
1358
+ return null;
1359
+ }
1360
+ scrollIn(options = {}) {
1361
+ if (!this.attached)
1362
+ return this;
1363
+ const resolved = CotomyScrollOptions.from(options);
1364
+ const behavior = resolved.resolveBehavior();
1365
+ const onlyIfNeeded = resolved.onlyIfNeeded;
1366
+ const block = resolved.block;
1367
+ const inline = resolved.inline;
1368
+ const scrollable = CotomyElement.findNearestScrollableAncestor(this.element);
1369
+ if (scrollable) {
1370
+ const elementRect = this.element.getBoundingClientRect();
1371
+ const containerRect = scrollable.getBoundingClientRect();
1372
+ const elementTopInContainer = elementRect.top - containerRect.top + scrollable.scrollTop;
1373
+ const elementBottomInContainer = elementRect.bottom - containerRect.top + scrollable.scrollTop;
1374
+ const elementLeftInContainer = elementRect.left - containerRect.left + scrollable.scrollLeft;
1375
+ const elementRightInContainer = elementRect.right - containerRect.left + scrollable.scrollLeft;
1376
+ const targetTop = CotomyElement.computeAlignedScroll(scrollable.scrollTop, scrollable.clientHeight, elementTopInContainer, elementBottomInContainer, block, onlyIfNeeded);
1377
+ const targetLeft = CotomyElement.computeAlignedScroll(scrollable.scrollLeft, scrollable.clientWidth, elementLeftInContainer, elementRightInContainer, inline, onlyIfNeeded);
1378
+ if (!onlyIfNeeded || targetTop !== null || targetLeft !== null) {
1379
+ const nextTop = targetTop ?? scrollable.scrollTop;
1380
+ const nextLeft = targetLeft ?? scrollable.scrollLeft;
1381
+ scrollable.scrollTo?.({ top: nextTop, left: nextLeft, behavior });
1382
+ if (!scrollable.scrollTo) {
1383
+ scrollable.scrollTop = nextTop;
1384
+ scrollable.scrollLeft = nextLeft;
1385
+ }
1386
+ }
1387
+ return this;
1388
+ }
1389
+ const rect = this.element.getBoundingClientRect();
1390
+ const currentTop = window.scrollY || document.documentElement.scrollTop;
1391
+ const currentLeft = window.scrollX || document.documentElement.scrollLeft;
1392
+ const elementTop = currentTop + rect.top;
1393
+ const elementBottom = currentTop + rect.bottom;
1394
+ const elementLeft = currentLeft + rect.left;
1395
+ const elementRight = currentLeft + rect.right;
1396
+ const targetTop = CotomyElement.computeAlignedScroll(currentTop, window.innerHeight, elementTop, elementBottom, block, onlyIfNeeded);
1397
+ const targetLeft = CotomyElement.computeAlignedScroll(currentLeft, window.innerWidth, elementLeft, elementRight, inline, onlyIfNeeded);
1398
+ if (!onlyIfNeeded || targetTop !== null || targetLeft !== null) {
1399
+ window.scrollTo({ top: targetTop ?? currentTop, left: targetLeft ?? currentLeft, behavior });
1400
+ }
1401
+ return this;
1402
+ }
1403
+ scrollTo(target, options = {}) {
1404
+ if (typeof target === "string") {
1405
+ const element = this.first(target);
1406
+ element?.scrollIn(options);
1407
+ return this;
1408
+ }
1409
+ if (target instanceof CotomyElement) {
1410
+ target.scrollIn(options);
1411
+ return this;
1412
+ }
1413
+ new CotomyElement(target).scrollIn(options);
1414
+ return this;
1415
+ }
1291
1416
  comesBefore(target) {
1292
1417
  const pos = this.element.compareDocumentPosition(target.element);
1293
1418
  if (pos & Node.DOCUMENT_POSITION_DISCONNECTED)
@@ -1993,6 +2118,19 @@ class CotomyWindow {
1993
2118
  return this.trigger("scroll");
1994
2119
  }
1995
2120
  }
2121
+ scrollTo(target, options = {}) {
2122
+ if (typeof target === "string") {
2123
+ const element = CotomyElement.first(target);
2124
+ element?.scrollIn(options);
2125
+ return this;
2126
+ }
2127
+ if (target instanceof CotomyElement) {
2128
+ target.scrollIn(options);
2129
+ return this;
2130
+ }
2131
+ new CotomyElement(target).scrollIn(options);
2132
+ return this;
2133
+ }
1996
2134
  changeLayout(handle) {
1997
2135
  if (handle) {
1998
2136
  return this.on("cotomy:changelayout", handle);
@@ -2276,9 +2414,16 @@ class CotomyViewRenderer {
2276
2414
  if (value !== undefined && value !== null) {
2277
2415
  const currency = element.attribute("data-cotomy-currency")
2278
2416
  || element.closest("[data-cotomy-currency]")?.attribute("data-cotomy-currency");
2279
- element.text = currency
2280
- ? new Intl.NumberFormat(this.locale, { style: "currency", currency: currency }).format(value)
2281
- : new Intl.NumberFormat(this.locale).format(value);
2417
+ const fractionDigitsAttribute = element.attribute("data-cotomy-fraction-digits")
2418
+ || element.closest("[data-cotomy-fraction-digits]")?.attribute("data-cotomy-fraction-digits");
2419
+ const fractionDigits = fractionDigitsAttribute ? Number.parseInt(fractionDigitsAttribute, 10) : undefined;
2420
+ const hasFractionDigits = Number.isFinite(fractionDigits)
2421
+ && fractionDigits && fractionDigits >= 0 && fractionDigits <= 20;
2422
+ const options = {
2423
+ ...(currency ? { style: "currency", currency } : {}),
2424
+ ...(hasFractionDigits ? { minimumFractionDigits: fractionDigits, maximumFractionDigits: fractionDigits } : {}),
2425
+ };
2426
+ element.text = new Intl.NumberFormat(this.locale, options).format(value);
2282
2427
  }
2283
2428
  });
2284
2429
  this.renderer("utc", (element, value) => {