@studious-creative/yumekit 0.1.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.
@@ -0,0 +1,335 @@
1
+ // helpers/slot-utils.js
2
+
3
+ /**
4
+ * Resolve a CSS custom-property value to a concrete color string.
5
+ * Reads from the given element's computed style.
6
+ * @param {string} varExpr — e.g. "var(--primary-content--)"
7
+ * @param {HTMLElement} el — element to resolve against
8
+ * @returns {string} — resolved color or fallback
9
+ */
10
+ function resolveCSSColor(varExpr, el) {
11
+ const match = varExpr.match(/var\(\s*(--[^,)]+)/);
12
+ if (!match) return varExpr;
13
+ const val = getComputedStyle(el).getPropertyValue(match[1]).trim();
14
+ return val || varExpr;
15
+ }
16
+
17
+ /**
18
+ * Parse a CSS color string (#hex, rgb(), etc.) to {r, g, b}.
19
+ * Returns null if it can't parse.
20
+ */
21
+ function parseColor(colorStr) {
22
+ // #RGB, #RRGGBB, #RRGGBBAA
23
+ const hexMatch = colorStr.match(
24
+ /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i,
25
+ );
26
+ if (hexMatch) {
27
+ let hex = hexMatch[1];
28
+ if (hex.length <= 4) {
29
+ hex = hex
30
+ .split("")
31
+ .map((c) => c + c)
32
+ .join("");
33
+ }
34
+ return {
35
+ r: parseInt(hex.slice(0, 2), 16),
36
+ g: parseInt(hex.slice(2, 4), 16),
37
+ b: parseInt(hex.slice(4, 6), 16),
38
+ };
39
+ }
40
+ // rgb(r, g, b) or rgba(r, g, b, a)
41
+ const rgbMatch = colorStr.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
42
+ if (rgbMatch) {
43
+ return {
44
+ r: parseInt(rgbMatch[1], 10),
45
+ g: parseInt(rgbMatch[2], 10),
46
+ b: parseInt(rgbMatch[3], 10),
47
+ };
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * Compute relative luminance of an {r,g,b} color (0-255 range).
54
+ * Returns a value between 0 (black) and 1 (white).
55
+ */
56
+ function luminance({ r, g, b }) {
57
+ const [rs, gs, bs] = [r, g, b].map((c) => {
58
+ c /= 255;
59
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
60
+ });
61
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
62
+ }
63
+
64
+ /**
65
+ * Given a background color string, return a CSS value for best contrast text.
66
+ * Uses WCAG relative luminance to pick dark or light, referencing theme tokens
67
+ * (--neutral-black / --neutral-white) with hardcoded fallbacks.
68
+ * @param {string} bgColor — any CSS color string (#hex, rgb(), etc.)
69
+ * @returns {string} CSS var() expression for contrasting text color
70
+ */
71
+ function contrastTextColor(bgColor) {
72
+ const parsed = parseColor(bgColor);
73
+ if (!parsed) return "var(--neutral-white, #ffffff)";
74
+ return luminance(parsed) > 0.179
75
+ ? "var(--neutral-black, #000000)"
76
+ : "var(--neutral-white, #ffffff)";
77
+ }
78
+
79
+ class YumeToast extends HTMLElement {
80
+ static get observedAttributes() {
81
+ return ["position", "duration", "max"];
82
+ }
83
+
84
+ constructor() {
85
+ super();
86
+ this.attachShadow({ mode: "open" });
87
+ this._queue = [];
88
+ }
89
+
90
+ connectedCallback() {
91
+ this.render();
92
+ }
93
+
94
+ attributeChangedCallback(name, oldVal, newVal) {
95
+ if (oldVal === newVal) return;
96
+ this.render();
97
+ }
98
+
99
+ get position() {
100
+ return this.getAttribute("position") || "bottom-right";
101
+ }
102
+ set position(val) {
103
+ this.setAttribute("position", val);
104
+ }
105
+
106
+ get duration() {
107
+ return parseInt(this.getAttribute("duration") ?? "4000", 10);
108
+ }
109
+ set duration(val) {
110
+ this.setAttribute("duration", String(val));
111
+ }
112
+
113
+ get max() {
114
+ return parseInt(this.getAttribute("max") ?? "5", 10);
115
+ }
116
+ set max(val) {
117
+ this.setAttribute("max", String(val));
118
+ }
119
+
120
+ /**
121
+ * Show a toast notification.
122
+ * @param {Object} opts
123
+ * @param {string} opts.message — Required text content.
124
+ * @param {string} [opts.color] — base|primary|secondary|success|warning|error|help (default base).
125
+ * @param {number} [opts.duration] — Override container-level duration for this toast.
126
+ * @param {boolean} [opts.dismissible] — Show a close button (default true).
127
+ * @param {string} [opts.icon] — Optional Font Awesome class e.g. "fas fa-check".
128
+ * @returns {HTMLElement} The toast element (for manual removal).
129
+ */
130
+ show(opts = {}) {
131
+ const {
132
+ message = "",
133
+ color = "base",
134
+ duration = this.duration,
135
+ dismissible = true,
136
+ icon = null,
137
+ } = opts;
138
+
139
+ const container = this.shadowRoot.querySelector(".toast-container");
140
+ if (!container) return null;
141
+
142
+ const existing = container.querySelectorAll(".toast");
143
+ if (existing.length >= this.max) {
144
+ this._removeToast(existing[0]);
145
+ }
146
+
147
+ const toast = document.createElement("div");
148
+ toast.className = `toast color-${color}`;
149
+ toast.setAttribute("role", "alert");
150
+ toast.setAttribute("aria-live", "assertive");
151
+
152
+ const bgVar = this._getColorBg(color);
153
+ const resolvedBg = resolveCSSColor(bgVar, this);
154
+ const textColor = contrastTextColor(resolvedBg);
155
+ toast.style.backgroundColor = bgVar;
156
+ toast.style.color = textColor;
157
+
158
+ if (icon) {
159
+ const iconEl = document.createElement("i");
160
+ iconEl.className = `toast-icon ${icon}`;
161
+ toast.appendChild(iconEl);
162
+ }
163
+
164
+ const msg = document.createElement("span");
165
+ msg.className = "toast-message";
166
+ msg.textContent = message;
167
+ toast.appendChild(msg);
168
+
169
+ if (dismissible) {
170
+ const btn = document.createElement("button");
171
+ btn.className = "toast-close";
172
+ btn.setAttribute("aria-label", "Dismiss");
173
+ btn.innerHTML = "&#215;";
174
+ btn.addEventListener("click", () => this._removeToast(toast));
175
+ toast.appendChild(btn);
176
+ }
177
+
178
+ container.appendChild(toast);
179
+
180
+ // Trigger enter animation (next frame)
181
+ requestAnimationFrame(() => {
182
+ toast.classList.add("visible");
183
+ });
184
+
185
+ if (duration > 0) {
186
+ toast._timeout = setTimeout(
187
+ () => this._removeToast(toast),
188
+ duration,
189
+ );
190
+ }
191
+
192
+ this.dispatchEvent(
193
+ new CustomEvent("y-toast-show", {
194
+ detail: { message, color },
195
+ bubbles: true,
196
+ }),
197
+ );
198
+
199
+ return toast;
200
+ }
201
+
202
+ clear() {
203
+ const container = this.shadowRoot.querySelector(".toast-container");
204
+ if (!container) return;
205
+ container
206
+ .querySelectorAll(".toast")
207
+ .forEach((t) => this._removeToast(t));
208
+ }
209
+
210
+ _removeToast(toast) {
211
+ if (!toast || toast._removing) return;
212
+ toast._removing = true;
213
+ clearTimeout(toast._timeout);
214
+ toast.classList.remove("visible");
215
+ toast.classList.add("exit");
216
+ toast.addEventListener(
217
+ "transitionend",
218
+ () => {
219
+ toast.remove();
220
+ this.dispatchEvent(
221
+ new CustomEvent("y-toast-dismiss", { bubbles: true }),
222
+ );
223
+ },
224
+ { once: true },
225
+ );
226
+ // Fallback if transition doesn't fire
227
+ setTimeout(() => {
228
+ if (toast.parentNode) {
229
+ toast.remove();
230
+ }
231
+ }, 350);
232
+ }
233
+
234
+ _getPositionStyles() {
235
+ const pos = this.position;
236
+ const base = `position: fixed; z-index: 10000; display: flex; flex-direction: column; gap: var(--spacing-small, 6px); pointer-events: none; max-width: 420px; min-width: 280px;`;
237
+ const pad = `var(--component-toast-offset, var(--spacing-x-large, 16px))`;
238
+
239
+ const map = {
240
+ "top-right": `${base} top: ${pad}; right: ${pad}; align-items: flex-end;`,
241
+ "top-left": `${base} top: ${pad}; left: ${pad}; align-items: flex-start;`,
242
+ "top-center": `${base} top: ${pad}; left: 50%; transform: translateX(-50%); align-items: center;`,
243
+ "bottom-right": `${base} bottom: ${pad}; right: ${pad}; align-items: flex-end;`,
244
+ "bottom-left": `${base} bottom: ${pad}; left: ${pad}; align-items: flex-start;`,
245
+ "bottom-center": `${base} bottom: ${pad}; left: 50%; transform: translateX(-50%); align-items: center;`,
246
+ };
247
+
248
+ return map[pos] || map["bottom-right"];
249
+ }
250
+
251
+ // "base" inverts the page: light content on dark, dark on light.
252
+ _getColorBg(color) {
253
+ const map = {
254
+ base: "var(--base-content--, #fff)",
255
+ primary: "var(--primary-content--, #0070f3)",
256
+ secondary: "var(--secondary-content--, #6c757d)",
257
+ success: "var(--success-content--, #28a745)",
258
+ warning: "var(--warning-content--, #ffc107)",
259
+ error: "var(--error-content--, #dc3545)",
260
+ help: "var(--help-content--, #6f42c1)",
261
+ };
262
+ return map[color] || map.base;
263
+ }
264
+
265
+ render() {
266
+ this.shadowRoot.innerHTML = `
267
+ <style>
268
+ :host {
269
+ font-family: var(--font-family-body, sans-serif);
270
+ }
271
+
272
+ .toast-container {
273
+ ${this._getPositionStyles()}
274
+ }
275
+
276
+ .toast {
277
+ pointer-events: auto;
278
+ display: flex;
279
+ align-items: center;
280
+ gap: var(--spacing-medium, 8px);
281
+ padding: var(--component-toast-padding, var(--spacing-medium, 8px));
282
+ border-radius: var(--component-toast-border-radius, var(--radii-small, 4px));
283
+ font-size: var(--font-size-paragraph, 1em);
284
+ line-height: 1.4;
285
+ opacity: 0;
286
+ transform: translateY(8px);
287
+ transition: opacity 0.25s ease, transform 0.25s ease;
288
+ box-shadow: var(--base-shadow, 0 2px 6px rgba(0,0,0,0.15));
289
+ }
290
+
291
+ .toast.visible {
292
+ opacity: 1;
293
+ transform: translateY(0);
294
+ }
295
+
296
+ .toast.exit {
297
+ opacity: 0;
298
+ transform: translateY(-8px);
299
+ }
300
+
301
+ .toast-icon {
302
+ flex-shrink: 0;
303
+ font-size: 1.1em;
304
+ }
305
+
306
+ .toast-message {
307
+ flex: 1;
308
+ }
309
+
310
+ .toast-close {
311
+ flex-shrink: 0;
312
+ align-self: flex-start;
313
+ background: none;
314
+ border: none;
315
+ color: inherit;
316
+ font-size: 1.2em;
317
+ cursor: pointer;
318
+ padding: 0 0 0 var(--spacing-small, 6px);
319
+ opacity: 0.7;
320
+ line-height: 1;
321
+ }
322
+ .toast-close:hover {
323
+ opacity: 1;
324
+ }
325
+ </style>
326
+ <div class="toast-container"></div>
327
+ `;
328
+ }
329
+ }
330
+
331
+ if (!customElements.get("y-toast")) {
332
+ customElements.define("y-toast", YumeToast);
333
+ }
334
+
335
+ export { YumeToast };
@@ -0,0 +1,320 @@
1
+ // helpers/slot-utils.js
2
+
3
+ /**
4
+ * Resolve a CSS custom-property value to a concrete color string.
5
+ * Reads from the given element's computed style.
6
+ * @param {string} varExpr — e.g. "var(--primary-content--)"
7
+ * @param {HTMLElement} el — element to resolve against
8
+ * @returns {string} — resolved color or fallback
9
+ */
10
+ function resolveCSSColor(varExpr, el) {
11
+ const match = varExpr.match(/var\(\s*(--[^,)]+)/);
12
+ if (!match) return varExpr;
13
+ const val = getComputedStyle(el).getPropertyValue(match[1]).trim();
14
+ return val || varExpr;
15
+ }
16
+
17
+ /**
18
+ * Parse a CSS color string (#hex, rgb(), etc.) to {r, g, b}.
19
+ * Returns null if it can't parse.
20
+ */
21
+ function parseColor(colorStr) {
22
+ // #RGB, #RRGGBB, #RRGGBBAA
23
+ const hexMatch = colorStr.match(
24
+ /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i,
25
+ );
26
+ if (hexMatch) {
27
+ let hex = hexMatch[1];
28
+ if (hex.length <= 4) {
29
+ hex = hex
30
+ .split("")
31
+ .map((c) => c + c)
32
+ .join("");
33
+ }
34
+ return {
35
+ r: parseInt(hex.slice(0, 2), 16),
36
+ g: parseInt(hex.slice(2, 4), 16),
37
+ b: parseInt(hex.slice(4, 6), 16),
38
+ };
39
+ }
40
+ // rgb(r, g, b) or rgba(r, g, b, a)
41
+ const rgbMatch = colorStr.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
42
+ if (rgbMatch) {
43
+ return {
44
+ r: parseInt(rgbMatch[1], 10),
45
+ g: parseInt(rgbMatch[2], 10),
46
+ b: parseInt(rgbMatch[3], 10),
47
+ };
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * Compute relative luminance of an {r,g,b} color (0-255 range).
54
+ * Returns a value between 0 (black) and 1 (white).
55
+ */
56
+ function luminance({ r, g, b }) {
57
+ const [rs, gs, bs] = [r, g, b].map((c) => {
58
+ c /= 255;
59
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
60
+ });
61
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
62
+ }
63
+
64
+ /**
65
+ * Given a background color string, return a CSS value for best contrast text.
66
+ * Uses WCAG relative luminance to pick dark or light, referencing theme tokens
67
+ * (--neutral-black / --neutral-white) with hardcoded fallbacks.
68
+ * @param {string} bgColor — any CSS color string (#hex, rgb(), etc.)
69
+ * @returns {string} CSS var() expression for contrasting text color
70
+ */
71
+ function contrastTextColor(bgColor) {
72
+ const parsed = parseColor(bgColor);
73
+ if (!parsed) return "var(--neutral-white, #ffffff)";
74
+ return luminance(parsed) > 0.179
75
+ ? "var(--neutral-black, #000000)"
76
+ : "var(--neutral-white, #ffffff)";
77
+ }
78
+
79
+ class YumeTooltip extends HTMLElement {
80
+ static get observedAttributes() {
81
+ return ["text", "position", "delay", "color"];
82
+ }
83
+
84
+ constructor() {
85
+ super();
86
+ this.attachShadow({ mode: "open" });
87
+ this._showTimeout = null;
88
+ this._hideTimeout = null;
89
+ this._visible = false;
90
+ this._onMouseEnter = this._onMouseEnter.bind(this);
91
+ this._onMouseLeave = this._onMouseLeave.bind(this);
92
+ this._onFocusIn = this._onFocusIn.bind(this);
93
+ this._onFocusOut = this._onFocusOut.bind(this);
94
+ this._onKeyDown = this._onKeyDown.bind(this);
95
+ }
96
+
97
+ connectedCallback() {
98
+ this.render();
99
+ this.addEventListener("mouseenter", this._onMouseEnter);
100
+ this.addEventListener("mouseleave", this._onMouseLeave);
101
+ this.addEventListener("focusin", this._onFocusIn);
102
+ this.addEventListener("focusout", this._onFocusOut);
103
+ document.addEventListener("keydown", this._onKeyDown);
104
+ }
105
+
106
+ disconnectedCallback() {
107
+ this.removeEventListener("mouseenter", this._onMouseEnter);
108
+ this.removeEventListener("mouseleave", this._onMouseLeave);
109
+ this.removeEventListener("focusin", this._onFocusIn);
110
+ this.removeEventListener("focusout", this._onFocusOut);
111
+ document.removeEventListener("keydown", this._onKeyDown);
112
+ clearTimeout(this._showTimeout);
113
+ clearTimeout(this._hideTimeout);
114
+ }
115
+
116
+ attributeChangedCallback(name, oldVal, newVal) {
117
+ if (oldVal === newVal) return;
118
+ this.render();
119
+ }
120
+
121
+ get text() {
122
+ return this.getAttribute("text") || "";
123
+ }
124
+ set text(val) {
125
+ this.setAttribute("text", val);
126
+ }
127
+
128
+ get position() {
129
+ return this.getAttribute("position") || "top";
130
+ }
131
+ set position(val) {
132
+ this.setAttribute("position", val);
133
+ }
134
+
135
+ get delay() {
136
+ return parseInt(this.getAttribute("delay") ?? "200", 10);
137
+ }
138
+ set delay(val) {
139
+ this.setAttribute("delay", String(val));
140
+ }
141
+
142
+ get color() {
143
+ return this.getAttribute("color") || "base";
144
+ }
145
+ set color(val) {
146
+ this.setAttribute("color", val);
147
+ }
148
+
149
+ show() {
150
+ clearTimeout(this._hideTimeout);
151
+ this._showTimeout = setTimeout(() => {
152
+ this._visible = true;
153
+ const tip = this.shadowRoot.querySelector(".tooltip");
154
+ if (tip) {
155
+ const bg = this._getBg();
156
+ const resolvedBg = resolveCSSColor(bg, this);
157
+ tip.style.color = contrastTextColor(resolvedBg);
158
+ tip.classList.add("visible");
159
+ }
160
+ }, this.delay);
161
+ }
162
+
163
+ hide() {
164
+ clearTimeout(this._showTimeout);
165
+ this._visible = false;
166
+ const tip = this.shadowRoot.querySelector(".tooltip");
167
+ if (tip) tip.classList.remove("visible");
168
+ }
169
+
170
+ _onMouseEnter() {
171
+ this.show();
172
+ }
173
+ _onMouseLeave() {
174
+ this.hide();
175
+ }
176
+ _onFocusIn() {
177
+ this.show();
178
+ }
179
+ _onFocusOut() {
180
+ this.hide();
181
+ }
182
+ _onKeyDown(e) {
183
+ if (e.key === "Escape" && this._visible) {
184
+ this.hide();
185
+ }
186
+ }
187
+
188
+ _getBg() {
189
+ const map = {
190
+ base: "var(--base-content--, #555)",
191
+ primary: "var(--primary-content--, #0070f3)",
192
+ secondary: "var(--secondary-content--, #6c757d)",
193
+ success: "var(--success-content--, #28a745)",
194
+ warning: "var(--warning-content--, #ffc107)",
195
+ error: "var(--error-content--, #dc3545)",
196
+ help: "var(--help-content--, #6f42c1)",
197
+ };
198
+ return map[this.color] || map.base;
199
+ }
200
+
201
+ render() {
202
+ const pos = this.position;
203
+ const bg = this._getBg();
204
+ const resolvedBg = resolveCSSColor(bg, this);
205
+ const fg = contrastTextColor(resolvedBg);
206
+
207
+ this.shadowRoot.innerHTML = `
208
+ <style>
209
+ :host {
210
+ display: inline-block;
211
+ position: relative;
212
+ font-family: var(--font-family-body, sans-serif);
213
+ }
214
+
215
+ .trigger {
216
+ display: inline-block;
217
+ }
218
+
219
+ .tooltip {
220
+ position: absolute;
221
+ z-index: 9999;
222
+ white-space: nowrap;
223
+ pointer-events: none;
224
+ opacity: 0;
225
+ transform: scale(0.95);
226
+ transition: opacity 0.15s ease, transform 0.15s ease;
227
+ padding: var(--component-tooltip-padding, var(--spacing-x-small, 4px)) var(--component-tooltip-padding-h, var(--spacing-medium, 8px));
228
+ border-radius: var(--component-tooltip-border-radius, var(--radii-small, 4px));
229
+ font-size: var(--font-size-small, 0.8em);
230
+ background: ${bg};
231
+ color: ${fg};
232
+ line-height: 1.4;
233
+ }
234
+
235
+ .tooltip.visible {
236
+ opacity: 1;
237
+ transform: scale(1);
238
+ }
239
+
240
+ .tooltip::after {
241
+ content: "";
242
+ position: absolute;
243
+ border: 5px solid transparent;
244
+ }
245
+
246
+ .tooltip.top {
247
+ bottom: 100%;
248
+ left: 50%;
249
+ transform: translateX(-50%) scale(0.95);
250
+ margin-bottom: 6px;
251
+ }
252
+ .tooltip.top.visible {
253
+ transform: translateX(-50%) scale(1);
254
+ }
255
+ .tooltip.top::after {
256
+ top: 100%;
257
+ left: 50%;
258
+ transform: translateX(-50%);
259
+ border-top-color: ${bg};
260
+ }
261
+
262
+ .tooltip.bottom {
263
+ top: 100%;
264
+ left: 50%;
265
+ transform: translateX(-50%) scale(0.95);
266
+ margin-top: 6px;
267
+ }
268
+ .tooltip.bottom.visible {
269
+ transform: translateX(-50%) scale(1);
270
+ }
271
+ .tooltip.bottom::after {
272
+ bottom: 100%;
273
+ left: 50%;
274
+ transform: translateX(-50%);
275
+ border-bottom-color: ${bg};
276
+ }
277
+
278
+ .tooltip.left {
279
+ right: 100%;
280
+ top: 50%;
281
+ transform: translateY(-50%) scale(0.95);
282
+ margin-right: 6px;
283
+ }
284
+ .tooltip.left.visible {
285
+ transform: translateY(-50%) scale(1);
286
+ }
287
+ .tooltip.left::after {
288
+ left: 100%;
289
+ top: 50%;
290
+ transform: translateY(-50%);
291
+ border-left-color: ${bg};
292
+ }
293
+
294
+ .tooltip.right {
295
+ left: 100%;
296
+ top: 50%;
297
+ transform: translateY(-50%) scale(0.95);
298
+ margin-left: 6px;
299
+ }
300
+ .tooltip.right.visible {
301
+ transform: translateY(-50%) scale(1);
302
+ }
303
+ .tooltip.right::after {
304
+ right: 100%;
305
+ top: 50%;
306
+ transform: translateY(-50%);
307
+ border-right-color: ${bg};
308
+ }
309
+ </style>
310
+ <slot class="trigger"></slot>
311
+ <div class="tooltip ${pos}" role="tooltip">${this.text}</div>
312
+ `;
313
+ }
314
+ }
315
+
316
+ if (!customElements.get("y-tooltip")) {
317
+ customElements.define("y-tooltip", YumeTooltip);
318
+ }
319
+
320
+ export { YumeTooltip };