@hypermedia-components/core 0.0.1-alpha.0

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.
Files changed (178) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/dist/anchor-fallback.d.ts +57 -0
  4. package/dist/anchor-fallback.js +198 -0
  5. package/dist/avatar.d.ts +10 -0
  6. package/dist/avatar.js +148 -0
  7. package/dist/calendar.d.ts +9 -0
  8. package/dist/calendar.js +559 -0
  9. package/dist/carousel.d.ts +7 -0
  10. package/dist/carousel.js +238 -0
  11. package/dist/chart.d.ts +28 -0
  12. package/dist/chart.js +277 -0
  13. package/dist/close-dialog.d.ts +28 -0
  14. package/dist/close-dialog.js +69 -0
  15. package/dist/close-popover.d.ts +23 -0
  16. package/dist/close-popover.js +63 -0
  17. package/dist/combobox.d.ts +9 -0
  18. package/dist/combobox.js +503 -0
  19. package/dist/command.d.ts +22 -0
  20. package/dist/command.js +407 -0
  21. package/dist/confirm.d.ts +28 -0
  22. package/dist/confirm.js +153 -0
  23. package/dist/context-menu.d.ts +9 -0
  24. package/dist/context-menu.js +188 -0
  25. package/dist/datagrid.d.ts +10 -0
  26. package/dist/datagrid.js +863 -0
  27. package/dist/drawer.d.ts +25 -0
  28. package/dist/drawer.js +202 -0
  29. package/dist/hc-accordion.css +140 -0
  30. package/dist/hc-alert.css +54 -0
  31. package/dist/hc-anchored.css +129 -0
  32. package/dist/hc-aspect.css +53 -0
  33. package/dist/hc-avatar.css +128 -0
  34. package/dist/hc-badge.css +45 -0
  35. package/dist/hc-breadcrumb.css +108 -0
  36. package/dist/hc-button-group.css +80 -0
  37. package/dist/hc-button.css +116 -0
  38. package/dist/hc-calendar.css +198 -0
  39. package/dist/hc-card.css +32 -0
  40. package/dist/hc-carousel.css +117 -0
  41. package/dist/hc-chart.css +57 -0
  42. package/dist/hc-checkbox.css +119 -0
  43. package/dist/hc-collapsible.css +106 -0
  44. package/dist/hc-combobox.css +167 -0
  45. package/dist/hc-command.css +162 -0
  46. package/dist/hc-datagrid.css +406 -0
  47. package/dist/hc-datepicker.css +158 -0
  48. package/dist/hc-dialog.css +52 -0
  49. package/dist/hc-drawer.css +185 -0
  50. package/dist/hc-empty.css +68 -0
  51. package/dist/hc-field.css +82 -0
  52. package/dist/hc-hovercard.css +97 -0
  53. package/dist/hc-input-group.css +90 -0
  54. package/dist/hc-input.css +92 -0
  55. package/dist/hc-inputotp.css +132 -0
  56. package/dist/hc-item.css +116 -0
  57. package/dist/hc-kbd.css +55 -0
  58. package/dist/hc-menu.css +205 -0
  59. package/dist/hc-menubar.css +53 -0
  60. package/dist/hc-multicombobox.css +206 -0
  61. package/dist/hc-navmenu.css +109 -0
  62. package/dist/hc-pagination.css +65 -0
  63. package/dist/hc-popover.css +31 -0
  64. package/dist/hc-progress.css +170 -0
  65. package/dist/hc-radio.css +111 -0
  66. package/dist/hc-scroll-area.css +86 -0
  67. package/dist/hc-select.css +124 -0
  68. package/dist/hc-separator.css +47 -0
  69. package/dist/hc-shell.css +259 -0
  70. package/dist/hc-skeleton.css +91 -0
  71. package/dist/hc-slider.css +218 -0
  72. package/dist/hc-spinner.css +52 -0
  73. package/dist/hc-splitter.css +90 -0
  74. package/dist/hc-switch.css +165 -0
  75. package/dist/hc-table.css +57 -0
  76. package/dist/hc-tabs.css +258 -0
  77. package/dist/hc-toast.css +135 -0
  78. package/dist/hc-toggle-group.css +124 -0
  79. package/dist/hc-toolbar.css +34 -0
  80. package/dist/hc-tooltip.css +61 -0
  81. package/dist/hc.a11y.css +151 -0
  82. package/dist/hc.base.css +34 -0
  83. package/dist/hc.behaviors.d.ts +31 -0
  84. package/dist/hc.behaviors.js +115 -0
  85. package/dist/hc.behaviors.min.js +2 -0
  86. package/dist/hc.core.css +1134 -0
  87. package/dist/hc.core.min.css +1 -0
  88. package/dist/hc.css +9568 -0
  89. package/dist/hc.htmx.css +50 -0
  90. package/dist/hc.min.css +1 -0
  91. package/dist/hc.min.js +2 -0
  92. package/dist/hc.tokens.color-amber.css +63 -0
  93. package/dist/hc.tokens.color-emerald.css +63 -0
  94. package/dist/hc.tokens.color-indigo.css +63 -0
  95. package/dist/hc.tokens.color-rose.css +63 -0
  96. package/dist/hc.tokens.core.css +1089 -0
  97. package/dist/hc.tokens.css +3061 -0
  98. package/dist/hc.tokens.density-compact.css +50 -0
  99. package/dist/hc.tokens.density-dense.css +50 -0
  100. package/dist/hc.tokens.neutral-neutral.css +410 -0
  101. package/dist/hc.tokens.neutral-slate.css +410 -0
  102. package/dist/hc.tokens.neutral-stone.css +410 -0
  103. package/dist/hc.tokens.neutral-zinc.css +410 -0
  104. package/dist/hc.utilities.css +111 -0
  105. package/dist/hovercard.d.ts +11 -0
  106. package/dist/hovercard.js +262 -0
  107. package/dist/i18n.d.ts +52 -0
  108. package/dist/i18n.js +100 -0
  109. package/dist/index.d.ts +32 -0
  110. package/dist/index.js +45 -0
  111. package/dist/inputotp.d.ts +9 -0
  112. package/dist/inputotp.js +225 -0
  113. package/dist/macros/confirm-action.d.ts +3 -0
  114. package/dist/macros/confirm-action.js +97 -0
  115. package/dist/macros/index.d.ts +4 -0
  116. package/dist/macros/index.js +16 -0
  117. package/dist/macros/index.min.js +1 -0
  118. package/dist/macros/live-search.d.ts +3 -0
  119. package/dist/macros/live-search.js +99 -0
  120. package/dist/menu-core.d.ts +30 -0
  121. package/dist/menu-core.js +166 -0
  122. package/dist/menu.d.ts +10 -0
  123. package/dist/menu.js +201 -0
  124. package/dist/menubar.d.ts +10 -0
  125. package/dist/menubar.js +232 -0
  126. package/dist/multicombobox.d.ts +10 -0
  127. package/dist/multicombobox.js +499 -0
  128. package/dist/navmenu.d.ts +9 -0
  129. package/dist/navmenu.js +336 -0
  130. package/dist/password-toggle.d.ts +8 -0
  131. package/dist/password-toggle.js +97 -0
  132. package/dist/popover.d.ts +9 -0
  133. package/dist/popover.js +133 -0
  134. package/dist/remote-dialog.d.ts +23 -0
  135. package/dist/remote-dialog.js +63 -0
  136. package/dist/shell.d.ts +11 -0
  137. package/dist/shell.js +260 -0
  138. package/dist/slider.d.ts +9 -0
  139. package/dist/slider.js +81 -0
  140. package/dist/splitter.d.ts +9 -0
  141. package/dist/splitter.js +238 -0
  142. package/dist/submenu.d.ts +20 -0
  143. package/dist/submenu.js +232 -0
  144. package/dist/tabs.d.ts +12 -0
  145. package/dist/tabs.js +333 -0
  146. package/dist/toast.d.ts +85 -0
  147. package/dist/toast.js +295 -0
  148. package/dist/toggle-group.d.ts +9 -0
  149. package/dist/toggle-group.js +271 -0
  150. package/dist/toolbar.d.ts +9 -0
  151. package/dist/toolbar.js +223 -0
  152. package/dist/tooltip.d.ts +9 -0
  153. package/dist/tooltip.js +197 -0
  154. package/dist/validation.d.ts +9 -0
  155. package/dist/validation.js +138 -0
  156. package/package.json +105 -0
  157. package/scripts/token-transform.mjs +309 -0
  158. package/src/tokens/README.md +10 -0
  159. package/src/tokens/color.amber.tokens.json +20 -0
  160. package/src/tokens/color.default.tokens.json +20 -0
  161. package/src/tokens/color.emerald.tokens.json +20 -0
  162. package/src/tokens/color.indigo.tokens.json +20 -0
  163. package/src/tokens/color.rose.tokens.json +20 -0
  164. package/src/tokens/component.tokens.json +951 -0
  165. package/src/tokens/density.comfortable.tokens.json +69 -0
  166. package/src/tokens/density.compact.tokens.json +69 -0
  167. package/src/tokens/density.dense.tokens.json +69 -0
  168. package/src/tokens/neutral.neutral.dark.tokens.json +71 -0
  169. package/src/tokens/neutral.neutral.tokens.json +67 -0
  170. package/src/tokens/neutral.slate.dark.tokens.json +71 -0
  171. package/src/tokens/neutral.slate.tokens.json +67 -0
  172. package/src/tokens/neutral.stone.dark.tokens.json +71 -0
  173. package/src/tokens/neutral.stone.tokens.json +67 -0
  174. package/src/tokens/neutral.zinc.dark.tokens.json +71 -0
  175. package/src/tokens/neutral.zinc.tokens.json +67 -0
  176. package/src/tokens/primitive.tokens.json +206 -0
  177. package/src/tokens/semantic.tokens.json +106 -0
  178. package/src/tokens/theme.dark.tokens.json +49 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ingcreators
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # @hypermedia-components/core
2
+
3
+ Semantic CSS components, DTCG-token-based themes, htmx-friendly recipes, small behavior helpers, and optional Light DOM macros.
4
+
5
+ See the [project documentation](https://ingcreators.com/hypermedia-components/) for usage.
6
+
7
+ ## Layout
8
+
9
+ ```text
10
+ src/
11
+ css/ Component, base, layer, and htmx CSS
12
+ js/ Behavior helpers (vanilla ESM)
13
+ macros/ Optional Light DOM custom-element macros
14
+ tokens/ DTCG token sources (primitive, semantic, component, theme, density)
15
+ dist/ Build output (hc.css, hc.tokens.css, hc.behaviors.js, hc.macros.js, ...)
16
+ ```
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Feature-detect CSS Anchor Positioning. Returns false where `CSS.supports`
3
+ * is missing (e.g. jsdom), which routes those environments through the
4
+ * fallback too.
5
+ *
6
+ * @returns {boolean}
7
+ */
8
+ export function supportsAnchorPositioning(): boolean;
9
+ /**
10
+ * Read a floating element's `data-side` / `data-align` attributes into the
11
+ * `{ side, align }` options {@link positionFloating} understands, falling
12
+ * back to the component's default when an attribute is absent or invalid.
13
+ * The CSS Anchor Positioning path keys off the same attributes
14
+ * (`position-area`), so both paths place the element identically.
15
+ *
16
+ * @param {Element} el
17
+ * @param {{ side?: string, align?: string }} [fallback]
18
+ * @returns {{ side: string, align: 'start'|'center'|'end' }}
19
+ */
20
+ export function readSideAlign(el: Element, fallback?: {
21
+ side?: string;
22
+ align?: string;
23
+ }): {
24
+ side: string;
25
+ align: "start" | "center" | "end";
26
+ };
27
+ /**
28
+ * Position `floating` next to `anchor`, once.
29
+ *
30
+ * @param {HTMLElement} floating the popover to place (already in the top layer)
31
+ * @param {HTMLElement} anchor the trigger to place it against
32
+ * @param {object} [opts]
33
+ * @param {'block-end'|'block-start'|'inline-end'|'inline-start'} [opts.side='block-end']
34
+ * primary side. The block sides drop the floating element below / above the
35
+ * anchor (dropdown); the inline sides place it to the right / left
36
+ * (submenu), aligning their block-start edges.
37
+ * @param {'start'|'center'} [opts.align='start'] inline-axis alignment (block sides only)
38
+ * @param {number} [opts.gap=4] distance from the anchor, px
39
+ * @param {boolean} [opts.matchWidth=false] set min-width to the anchor width
40
+ */
41
+ export function positionFloating(floating: HTMLElement, anchor: HTMLElement, opts?: {
42
+ side?: "block-end" | "block-start" | "inline-end" | "inline-start";
43
+ align?: "start" | "center";
44
+ gap?: number;
45
+ matchWidth?: boolean;
46
+ }): void;
47
+ /**
48
+ * Position `floating` against `anchor` now and keep it tracking while open.
49
+ * Re-runs on scroll (in any ancestor, via capture) and on resize.
50
+ *
51
+ * @param {HTMLElement} floating
52
+ * @param {HTMLElement} anchor
53
+ * @param {object} [opts] see {@link positionFloating}
54
+ * @returns {() => void} cleanup — removes the listeners and clears the inline
55
+ * styles. Idempotent.
56
+ */
57
+ export function trackFloating(floating: HTMLElement, anchor: HTMLElement, opts?: object): () => void;
@@ -0,0 +1,198 @@
1
+ // Shared fallback positioning for popovers when CSS Anchor Positioning is
2
+ // unavailable (e.g. current Firefox). The components position their popovers
3
+ // with CSS anchor positioning + `position-try-fallbacks`; in engines without
4
+ // it, a `[popover]` would otherwise sit centred in the viewport. This module
5
+ // mirrors that CSS behaviour in JS: place the floating element next to its
6
+ // anchor, flip on overflow, clamp to the viewport, and keep it tracking on
7
+ // scroll / resize until cleaned up.
8
+ //
9
+ // Geometry is set with PHYSICAL `top` / `left` (computed from
10
+ // getBoundingClientRect, which is physical), so it is correct under both LTR
11
+ // and RTL; inline-axis *alignment* is direction-aware.
12
+
13
+ /**
14
+ * Feature-detect CSS Anchor Positioning. Returns false where `CSS.supports`
15
+ * is missing (e.g. jsdom), which routes those environments through the
16
+ * fallback too.
17
+ *
18
+ * @returns {boolean}
19
+ */
20
+ export function supportsAnchorPositioning() {
21
+ try {
22
+ return (
23
+ typeof CSS !== 'undefined' &&
24
+ typeof CSS.supports === 'function' &&
25
+ CSS.supports('anchor-name', '--x')
26
+ );
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ const clamp = (value, min, max) => Math.max(min, Math.min(value, max));
33
+
34
+ // data-side (physical) → fallback `side` (logical block / inline axis).
35
+ const SIDE_TO_AXIS = {
36
+ top: 'block-start',
37
+ bottom: 'block-end',
38
+ left: 'inline-start',
39
+ right: 'inline-end',
40
+ };
41
+
42
+ /**
43
+ * Read a floating element's `data-side` / `data-align` attributes into the
44
+ * `{ side, align }` options {@link positionFloating} understands, falling
45
+ * back to the component's default when an attribute is absent or invalid.
46
+ * The CSS Anchor Positioning path keys off the same attributes
47
+ * (`position-area`), so both paths place the element identically.
48
+ *
49
+ * @param {Element} el
50
+ * @param {{ side?: string, align?: string }} [fallback]
51
+ * @returns {{ side: string, align: 'start'|'center'|'end' }}
52
+ */
53
+ export function readSideAlign(el, fallback = {}) {
54
+ const side = SIDE_TO_AXIS[el.getAttribute('data-side')] ?? fallback.side ?? 'block-end';
55
+ const alignAttr = el.getAttribute('data-align');
56
+ const align = ['start', 'center', 'end'].includes(alignAttr)
57
+ ? alignAttr
58
+ : fallback.align ?? 'start';
59
+ return { side, align };
60
+ }
61
+
62
+ /**
63
+ * Position `floating` next to `anchor`, once.
64
+ *
65
+ * @param {HTMLElement} floating the popover to place (already in the top layer)
66
+ * @param {HTMLElement} anchor the trigger to place it against
67
+ * @param {object} [opts]
68
+ * @param {'block-end'|'block-start'|'inline-end'|'inline-start'} [opts.side='block-end']
69
+ * primary side. The block sides drop the floating element below / above the
70
+ * anchor (dropdown); the inline sides place it to the right / left
71
+ * (submenu), aligning their block-start edges.
72
+ * @param {'start'|'center'} [opts.align='start'] inline-axis alignment (block sides only)
73
+ * @param {number} [opts.gap=4] distance from the anchor, px
74
+ * @param {boolean} [opts.matchWidth=false] set min-width to the anchor width
75
+ */
76
+ export function positionFloating(floating, anchor, opts = {}) {
77
+ const { side = 'block-end', align = 'start', gap = 4, matchWidth = false } = opts;
78
+ const a = anchor.getBoundingClientRect();
79
+ const f = floating.getBoundingClientRect();
80
+ const view = floating.ownerDocument.defaultView;
81
+ const vw = view?.innerWidth ?? 0;
82
+ const vh = view?.innerHeight ?? 0;
83
+ const rtl = view ? view.getComputedStyle(anchor).direction === 'rtl' : false;
84
+
85
+ // Inline sides (submenu): place beside the anchor, align block tops.
86
+ if (side === 'inline-end' || side === 'inline-start') {
87
+ // `inline-end` resolves to the physical right in LTR, left in RTL.
88
+ const toRight = (side === 'inline-end') !== rtl;
89
+ let left;
90
+ if (toRight) {
91
+ left = a.right + gap;
92
+ if (left + f.width > vw && a.left - f.width - gap >= 0) left = a.left - f.width - gap;
93
+ } else {
94
+ left = a.left - f.width - gap;
95
+ if (left < 0 && a.right + f.width + gap <= vw) left = a.right + gap;
96
+ }
97
+ // Cross axis (block): align start (tops) / center / end (bottoms),
98
+ // flipping the chosen edge when it would overflow.
99
+ let top;
100
+ if (align === 'center') {
101
+ top = a.top + (a.height - f.height) / 2;
102
+ } else if (align === 'end') {
103
+ top = a.bottom - f.height;
104
+ if (top < 0 && a.top + f.height <= vh) top = a.top;
105
+ } else {
106
+ top = a.top;
107
+ if (top + f.height > vh && a.bottom - f.height >= 0) top = a.bottom - f.height;
108
+ }
109
+ top = clamp(top, gap, Math.max(gap, vh - f.height - gap));
110
+ left = clamp(left, gap, Math.max(gap, vw - f.width - gap));
111
+
112
+ floating.style.position = 'fixed';
113
+ floating.style.top = `${top}px`;
114
+ floating.style.left = `${left}px`;
115
+ floating.style.insetInlineStart = 'auto';
116
+ floating.style.insetBlockStart = 'auto';
117
+ floating.style.margin = '0';
118
+ if (matchWidth) floating.style.minWidth = `${a.width}px`;
119
+ return;
120
+ }
121
+
122
+ // Block axis: primary side, flip when it would overflow and there is room.
123
+ let top;
124
+ if (side === 'block-start') {
125
+ top = a.top - f.height - gap;
126
+ if (top < 0 && a.bottom + f.height + gap <= vh) top = a.bottom + gap;
127
+ } else {
128
+ top = a.bottom + gap;
129
+ if (top + f.height > vh && a.top - f.height - gap >= 0) top = a.top - f.height - gap;
130
+ }
131
+
132
+ // Inline axis: align start / center / end, then flip on overflow.
133
+ let left;
134
+ if (align === 'center') {
135
+ left = a.left + (a.width - f.width) / 2;
136
+ } else {
137
+ // `start` aligns the inline-start edges, `end` the inline-end edges; RTL
138
+ // swaps which physical edge each maps to.
139
+ const startToLeft = (align !== 'end') !== rtl;
140
+ if (startToLeft) {
141
+ left = a.left;
142
+ if (left + f.width > vw && a.right - f.width >= 0) left = a.right - f.width;
143
+ } else {
144
+ left = a.right - f.width;
145
+ if (left < 0 && a.left + f.width <= vw) left = a.left;
146
+ }
147
+ }
148
+
149
+ // Final safety clamp so it can never sit fully off-screen.
150
+ top = clamp(top, gap, Math.max(gap, vh - f.height - gap));
151
+ left = clamp(left, gap, Math.max(gap, vw - f.width - gap));
152
+
153
+ floating.style.position = 'fixed';
154
+ floating.style.top = `${top}px`;
155
+ floating.style.left = `${left}px`;
156
+ floating.style.insetInlineStart = 'auto';
157
+ floating.style.insetBlockStart = 'auto';
158
+ floating.style.margin = '0';
159
+ if (matchWidth) floating.style.minWidth = `${a.width}px`;
160
+ }
161
+
162
+ const CLEARED = [
163
+ 'position',
164
+ 'top',
165
+ 'left',
166
+ 'inset-inline-start',
167
+ 'inset-block-start',
168
+ 'margin',
169
+ 'min-width',
170
+ ];
171
+
172
+ /**
173
+ * Position `floating` against `anchor` now and keep it tracking while open.
174
+ * Re-runs on scroll (in any ancestor, via capture) and on resize.
175
+ *
176
+ * @param {HTMLElement} floating
177
+ * @param {HTMLElement} anchor
178
+ * @param {object} [opts] see {@link positionFloating}
179
+ * @returns {() => void} cleanup — removes the listeners and clears the inline
180
+ * styles. Idempotent.
181
+ */
182
+ export function trackFloating(floating, anchor, opts = {}) {
183
+ const view = floating.ownerDocument.defaultView;
184
+ const reposition = () => positionFloating(floating, anchor, opts);
185
+ reposition();
186
+ // capture:true so scrolls in ancestor scroll containers are caught too.
187
+ view?.addEventListener('scroll', reposition, true);
188
+ view?.addEventListener('resize', reposition);
189
+
190
+ let done = false;
191
+ return () => {
192
+ if (done) return;
193
+ done = true;
194
+ view?.removeEventListener('scroll', reposition, true);
195
+ view?.removeEventListener('resize', reposition);
196
+ for (const prop of CLEARED) floating.style.removeProperty(prop);
197
+ };
198
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Install the avatar behavior on every composite `.hc-avatar` (one holding a
3
+ * `.hc-avatar__image`) in the document: track the image's load / error state
4
+ * in `data-state` so the initials fallback shows when the image is missing.
5
+ * The CSS works without it; this adds the automatic swap.
6
+ *
7
+ * @param {Document|Element} [root]
8
+ * @returns {() => void}
9
+ */
10
+ export function installAvatar(root?: Document | Element): () => void;
package/dist/avatar.js ADDED
@@ -0,0 +1,148 @@
1
+ // installAvatar — image load/error → initials fallback for hc-avatar.
2
+ //
3
+ // Drives the `data-state` of a composite avatar off the native image
4
+ // `load` / `error` events — no network of its own:
5
+ //
6
+ // <span class="hc-avatar" role="img" aria-label="Ada Lovelace">
7
+ // <img class="hc-avatar__image" src="/ada.jpg" alt="">
8
+ // <span class="hc-avatar__fallback" aria-hidden="true">AL</span>
9
+ // </span>
10
+ //
11
+ // State machine (written to `data-state` on the wrapper):
12
+ //
13
+ // loading — image is fetching; the fallback shows so the slot is never
14
+ // empty (this is the default while waiting).
15
+ // pending — image is fetching but `data-delay="<ms>"` is set, so the
16
+ // fallback stays hidden for that window to avoid a flash on a
17
+ // fast connection. Becomes `loading` when the delay elapses.
18
+ // loaded — image decoded successfully; the fallback is hidden.
19
+ // error — the image failed or has no `src`; the fallback shows and the
20
+ // broken image is removed from the box.
21
+ //
22
+ // Each change dispatches a bubbling `hc:avatarstatechange` (detail.state).
23
+ //
24
+ // Only composite avatars (a `.hc-avatar` with a `.hc-avatar__image` child)
25
+ // are managed; plain `<img class="hc-avatar">` / `<span class="hc-avatar">`
26
+ // avatars are left untouched. Progressive: with JS off the image still
27
+ // covers the fallback when it loads (a broken image shows the fallback
28
+ // behind it).
29
+ //
30
+ // installAvatar(root = document) returns an idempotent uninstaller.
31
+
32
+ const INSTALL_KEY = '__hcAvatarUninstall';
33
+
34
+ function imageOf(avatar) {
35
+ return avatar.querySelector(':scope > .hc-avatar__image');
36
+ }
37
+
38
+ function hasSrc(img) {
39
+ const src = img.getAttribute('src');
40
+ return src != null && src.trim() !== '';
41
+ }
42
+
43
+ // Synchronous verdict for an image that may already be settled (e.g. served
44
+ // from cache before the behavior runs). null means "still loading".
45
+ function settle(img) {
46
+ if (!hasSrc(img)) return 'error';
47
+ if (img.complete) return img.naturalWidth > 0 ? 'loaded' : 'error';
48
+ return null;
49
+ }
50
+
51
+ function setState(avatar, state) {
52
+ if (avatar.dataset.state === state) return;
53
+ avatar.dataset.state = state;
54
+ avatar.dispatchEvent(
55
+ new CustomEvent('hc:avatarstatechange', { bubbles: true, detail: { state } }),
56
+ );
57
+ }
58
+
59
+ function attach(avatar, detachers) {
60
+ if (detachers.has(avatar)) return;
61
+ const img = imageOf(avatar);
62
+ if (!img) return; // a plain avatar — nothing to manage
63
+
64
+ let timer = null;
65
+ const clearTimer = () => {
66
+ if (timer != null) {
67
+ clearTimeout(timer);
68
+ timer = null;
69
+ }
70
+ };
71
+
72
+ function onLoad() {
73
+ clearTimer();
74
+ setState(avatar, 'loaded');
75
+ }
76
+ function onError() {
77
+ clearTimer();
78
+ setState(avatar, 'error');
79
+ }
80
+
81
+ const initial = settle(img);
82
+ if (initial === 'loaded' || initial === 'error') {
83
+ setState(avatar, initial);
84
+ } else {
85
+ const delay = Math.max(0, parseInt(avatar.getAttribute('data-delay'), 10) || 0);
86
+ if (delay > 0) {
87
+ setState(avatar, 'pending');
88
+ timer = setTimeout(() => {
89
+ timer = null;
90
+ if (avatar.dataset.state === 'pending') setState(avatar, 'loading');
91
+ }, delay);
92
+ } else {
93
+ setState(avatar, 'loading');
94
+ }
95
+ img.addEventListener('load', onLoad);
96
+ img.addEventListener('error', onError);
97
+ }
98
+
99
+ detachers.set(avatar, () => {
100
+ clearTimer();
101
+ img.removeEventListener('load', onLoad);
102
+ img.removeEventListener('error', onError);
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Install the avatar behavior on every composite `.hc-avatar` (one holding a
108
+ * `.hc-avatar__image`) in the document: track the image's load / error state
109
+ * in `data-state` so the initials fallback shows when the image is missing.
110
+ * The CSS works without it; this adds the automatic swap.
111
+ *
112
+ * @param {Document|Element} [root]
113
+ * @returns {() => void}
114
+ */
115
+ export function installAvatar(
116
+ root = typeof document !== 'undefined' ? document : null,
117
+ ) {
118
+ if (!root) return () => {};
119
+ if (root[INSTALL_KEY]) return root[INSTALL_KEY];
120
+
121
+ const detachers = new Map();
122
+
123
+ for (const el of root.querySelectorAll('.hc-avatar')) attach(el, detachers);
124
+
125
+ let observer = null;
126
+ if (typeof MutationObserver !== 'undefined') {
127
+ observer = new MutationObserver((records) => {
128
+ for (const rec of records) {
129
+ for (const node of rec.addedNodes) {
130
+ if (node.nodeType !== 1) continue;
131
+ if (node.matches?.('.hc-avatar')) attach(node, detachers);
132
+ node.querySelectorAll?.('.hc-avatar').forEach((el) => attach(el, detachers));
133
+ }
134
+ }
135
+ });
136
+ observer.observe(root.body ?? root, { childList: true, subtree: true });
137
+ }
138
+
139
+ const uninstall = () => {
140
+ if (root[INSTALL_KEY] !== uninstall) return;
141
+ if (observer) observer.disconnect();
142
+ for (const detach of detachers.values()) detach();
143
+ detachers.clear();
144
+ delete root[INSTALL_KEY];
145
+ };
146
+ root[INSTALL_KEY] = uninstall;
147
+ return uninstall;
148
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Install the calendar behavior on every `.hc-calendar` container in the
3
+ * document. The returned uninstaller is idempotent and a no-op when the
4
+ * behavior is not installed.
5
+ *
6
+ * @param {Document|Element} [root]
7
+ * @returns {() => void}
8
+ */
9
+ export function installCalendar(root?: Document | Element): () => void;