@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,484 @@
1
+ /* ================================================================== */
2
+ /* Centralized SVG icon strings for the YumeKit component library. */
3
+ /* */
4
+ /* Each static icon also lives in its own .svg file in this directory */
5
+ /* so it can be used standalone (e.g. <img src="…">, CSS background, */
6
+ /* design tools, etc.). The strings below mirror those files — keep */
7
+ /* them in sync when editing an icon. */
8
+ /* ================================================================== */
9
+
10
+
11
+ const chevronDownLg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20" aria-hidden="true"><path d="M5 7 L10 12 L15 7" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" /></svg>`;
12
+
13
+ class YumeSelect extends HTMLElement {
14
+ static formAssociated = true;
15
+
16
+ static get observedAttributes() {
17
+ return [
18
+ "label-position",
19
+ "disabled",
20
+ "invalid",
21
+ "required",
22
+ "value",
23
+ "name",
24
+ "placeholder",
25
+ "options",
26
+ "display-mode",
27
+ ];
28
+ }
29
+
30
+ constructor() {
31
+ super();
32
+ this._internals = this.attachInternals();
33
+ this.attachShadow({ mode: "open" });
34
+ this.selectedValues = new Set();
35
+ this.render();
36
+ }
37
+
38
+ connectedCallback() {
39
+ if (!this.hasAttribute("label-position")) {
40
+ this.setAttribute("label-position", "top");
41
+ }
42
+ this.updateValidation();
43
+ this._internals.setFormValue(this.value);
44
+ }
45
+
46
+ attributeChangedCallback(name, oldValue, newValue) {
47
+ if (oldValue === newValue) return;
48
+
49
+ if (name === "value") {
50
+ this.updateDisplay();
51
+ this._internals.setFormValue(newValue, this.getAttribute("name"));
52
+ this.updateSelectedStyles();
53
+ }
54
+
55
+ if (
56
+ [
57
+ "label-position",
58
+ "disabled",
59
+ "invalid",
60
+ "required",
61
+ "placeholder",
62
+ "options",
63
+ ].includes(name)
64
+ ) {
65
+ this.render();
66
+ }
67
+
68
+ if (name === "name") {
69
+ this._internals.setFormValue(this.value, newValue);
70
+ }
71
+ }
72
+
73
+ get value() {
74
+ if (this.hasAttribute("multiple")) {
75
+ return Array.from(this.selectedValues).join(",");
76
+ }
77
+ return this._value || "";
78
+ }
79
+
80
+ set value(val) {
81
+ if (this.hasAttribute("multiple")) {
82
+ if (typeof val === "string") {
83
+ this.selectedValues = new Set(
84
+ val.split(",").map((v) => v.trim()),
85
+ );
86
+ } else if (Array.isArray(val)) {
87
+ this.selectedValues = new Set(val);
88
+ }
89
+ } else {
90
+ this._value = val;
91
+ }
92
+
93
+ this.setAttribute("value", val);
94
+ this._internals.setFormValue(this.value, this.getAttribute("name"));
95
+ this.updateDisplay();
96
+ this.updateSelectedStyles();
97
+ }
98
+
99
+ getOptions() {
100
+ try {
101
+ return JSON.parse(this.getAttribute("options") || "[]");
102
+ } catch (e) {
103
+ return [];
104
+ }
105
+ }
106
+
107
+ getDisplayText() {
108
+ const options = this.getOptions();
109
+ const isMulti = this.hasAttribute("multiple");
110
+ const isTagMode = this.getAttribute("display-mode") === "tag";
111
+
112
+ if (isMulti && isTagMode) {
113
+ return "";
114
+ }
115
+
116
+ if (isMulti) {
117
+ const count = options.filter((opt) =>
118
+ this.selectedValues.has(opt.value),
119
+ ).length;
120
+ return count > 0
121
+ ? `${count} Selected`
122
+ : this.getAttribute("placeholder") || "Select...";
123
+ } else {
124
+ const selected = options.find((opt) => opt.value === this.value);
125
+ return (
126
+ selected?.label ||
127
+ this.getAttribute("placeholder") ||
128
+ "Select..."
129
+ );
130
+ }
131
+ }
132
+
133
+ toggleDropdown() {
134
+ const isOpen = this.dropdown.classList.contains("open");
135
+ if (isOpen) {
136
+ this.closeDropdown();
137
+ } else {
138
+ this.dropdown.classList.add("open");
139
+ this.selectContainer.classList.add("open");
140
+ this._positionDropdown();
141
+ this._onScrollOrResize = this._positionDropdown.bind(this);
142
+ window.addEventListener("scroll", this._onScrollOrResize, true);
143
+ window.addEventListener("resize", this._onScrollOrResize);
144
+ }
145
+ }
146
+
147
+ closeDropdown() {
148
+ this.dropdown.classList.remove("open");
149
+ this.selectContainer.classList.remove("open");
150
+ if (this._onScrollOrResize) {
151
+ window.removeEventListener("scroll", this._onScrollOrResize, true);
152
+ window.removeEventListener("resize", this._onScrollOrResize);
153
+ }
154
+ }
155
+
156
+ _positionDropdown() {
157
+ const rect = this.selectContainer.getBoundingClientRect();
158
+ const gap = 4;
159
+ this.dropdown.style.left = `${rect.left}px`;
160
+ this.dropdown.style.width = `${rect.width}px`;
161
+
162
+ const spaceBelow = window.innerHeight - rect.bottom - gap;
163
+ const maxH = 200;
164
+ if (spaceBelow >= maxH || spaceBelow >= rect.top) {
165
+ this.dropdown.style.top = `${rect.bottom + gap}px`;
166
+ this.dropdown.style.bottom = "auto";
167
+ } else {
168
+ this.dropdown.style.bottom = `${window.innerHeight - rect.top + gap}px`;
169
+ this.dropdown.style.top = "auto";
170
+ }
171
+ }
172
+
173
+ queryRefs() {
174
+ this.selectContainer =
175
+ this.shadowRoot.querySelector(".select-container");
176
+ this.dropdown = this.shadowRoot.querySelector(".dropdown");
177
+ this.labelWrapper = this.shadowRoot.querySelector(".label-wrapper");
178
+ this.displayElement = this.shadowRoot.querySelector(".value-display");
179
+ }
180
+
181
+ attachEventListeners() {
182
+ this.selectContainer.addEventListener("click", () =>
183
+ this.toggleDropdown(),
184
+ );
185
+
186
+ this.dropdown.querySelectorAll(".dropdown-item").forEach((item) => {
187
+ item.addEventListener("click", () => {
188
+ const val = item.getAttribute("data-value");
189
+ const isRequired = this.hasAttribute("required");
190
+ const isMulti = this.hasAttribute("multiple");
191
+
192
+ if (isMulti) {
193
+ if (this.selectedValues.has(val)) {
194
+ if (!isRequired || this.selectedValues.size > 1) {
195
+ this.selectedValues.delete(val);
196
+ }
197
+ } else {
198
+ this.selectedValues.add(val);
199
+ }
200
+
201
+ this.setAttribute(
202
+ "value",
203
+ Array.from(this.selectedValues).join(","),
204
+ );
205
+ } else {
206
+ const isSelected = val === this.value;
207
+ if (isSelected && !isRequired) {
208
+ this.value = "";
209
+ } else {
210
+ this.value = val;
211
+ }
212
+ }
213
+
214
+ this.dispatchEvent(
215
+ new CustomEvent("change", {
216
+ detail: { value: this.value },
217
+ bubbles: true,
218
+ composed: true,
219
+ }),
220
+ );
221
+
222
+ this.updateValidation();
223
+ this.closeDropdown();
224
+ });
225
+ });
226
+ }
227
+
228
+ renderTags() {
229
+ const isMulti = this.hasAttribute("multiple");
230
+ const isTagMode = this.getAttribute("display-mode") === "tag";
231
+ if (!isMulti || !isTagMode || !this.displayElement) return;
232
+
233
+ const options = this.getOptions();
234
+ this.displayElement.innerHTML = "";
235
+
236
+ const selected = options.filter((opt) =>
237
+ this.selectedValues.has(opt.value),
238
+ );
239
+
240
+ selected.forEach((opt) => {
241
+ const tag = document.createElement("y-tag");
242
+ tag.setAttribute("removable", "");
243
+ tag.setAttribute("color", "primary");
244
+ tag.setAttribute("style-type", "filled");
245
+ tag.textContent = opt.label;
246
+ tag.dataset.value = opt.value;
247
+
248
+ tag.addEventListener("remove", () => {
249
+ this.selectedValues.delete(opt.value);
250
+ this.setAttribute(
251
+ "value",
252
+ Array.from(this.selectedValues).join(","),
253
+ );
254
+ this.renderTags();
255
+ this.updateSelectedStyles();
256
+ this.updateValidation();
257
+
258
+ this.dispatchEvent(
259
+ new CustomEvent("change", {
260
+ detail: { value: this.value },
261
+ bubbles: true,
262
+ composed: true,
263
+ }),
264
+ );
265
+ });
266
+
267
+ this.displayElement.appendChild(tag);
268
+ });
269
+ }
270
+
271
+ updateDisplay() {
272
+ const isTagMode = this.getAttribute("display-mode") === "tag";
273
+
274
+ if (isTagMode) {
275
+ this.renderTags();
276
+ } else if (this.displayElement) {
277
+ this.displayElement.textContent = this.getDisplayText();
278
+ }
279
+ }
280
+
281
+ updateSelectedStyles() {
282
+ const isMulti = this.hasAttribute("multiple");
283
+ const valueSet = isMulti ? this.selectedValues : new Set([this.value]);
284
+
285
+ this.dropdown?.querySelectorAll(".dropdown-item").forEach((item) => {
286
+ const val = item.getAttribute("data-value");
287
+ item.classList.toggle("selected", valueSet.has(val));
288
+ });
289
+ }
290
+
291
+ updateValidation() {
292
+ const required = this.hasAttribute("required");
293
+ const isMulti = this.hasAttribute("multiple");
294
+ const isValid = isMulti
295
+ ? this.selectedValues.size > 0
296
+ : this.value && this.value !== "";
297
+
298
+ if (required && !isValid) {
299
+ this.setAttribute("invalid", "");
300
+ } else {
301
+ this.removeAttribute("invalid");
302
+ }
303
+ }
304
+
305
+ updateValidationState() {
306
+ const isInvalid = this.hasAttribute("invalid");
307
+ this.selectContainer?.classList.toggle("is-invalid", isInvalid);
308
+ this.labelWrapper?.classList.toggle("is-invalid", isInvalid);
309
+ }
310
+
311
+ render() {
312
+ this.applyStyles();
313
+ this.shadowRoot.innerHTML = this.generateTemplate();
314
+ this.queryRefs();
315
+ this.attachEventListeners();
316
+ this.updateValidationState();
317
+ }
318
+
319
+ applyStyles() {
320
+ const isDisabled = this.hasAttribute("disabled");
321
+
322
+ const sheet = new CSSStyleSheet();
323
+ sheet.replaceSync(`
324
+ :host {
325
+ display: block;
326
+ font-family: var(--font-family-body);
327
+ color: var(--component-select-color);
328
+ opacity: ${isDisabled ? "0.75" : "1"};
329
+ pointer-events: ${isDisabled ? "none" : "auto"};
330
+ }
331
+
332
+ .select-wrapper {
333
+ display: flex;
334
+ flex-direction: column;
335
+ gap: var(--spacing-2x-small, 4px);
336
+ position: relative;
337
+ }
338
+
339
+ .select-container {
340
+ display: flex;
341
+ align-items: center;
342
+ gap: var(--spacing-x-small);
343
+ background: var(--component-select-background);
344
+ border: var(--component-inputs-border-width) solid var(--component-select-border-color);
345
+ border-radius: var(--component-inputs-border-radius-outer);
346
+ padding: var(--component-inputs-padding-medium);
347
+ box-sizing: border-box;
348
+ transition: border-color 0.2s ease-in-out;
349
+ cursor: pointer;
350
+ }
351
+
352
+ .select-container:hover {
353
+ border-color: var(--component-select-color);
354
+ }
355
+
356
+ .select-container:focus-within {
357
+ border-color: var(--component-select-accent);
358
+ }
359
+
360
+ .select-container.is-invalid {
361
+ border-color: var(--component-select-error-border-color);
362
+ background: var(--component-select-error-background);
363
+ }
364
+
365
+ .select-container.is-invalid:hover {
366
+ border-color: var(--component-select-error-color);
367
+ }
368
+
369
+ .select-container.is-invalid:focus-within {
370
+ border-color: var(--component-select-error-color);
371
+ }
372
+
373
+ .label-wrapper.is-invalid ::slotted([slot="label"]) {
374
+ color: var(--component-select-error-color);
375
+ }
376
+
377
+ ::slotted([slot="label"]) {
378
+ font-weight: 500;
379
+ font-size: 0.875em;
380
+ color: var(--component-select-label-color);
381
+ }
382
+
383
+ .dropdown {
384
+ position: fixed;
385
+ z-index: 9999;
386
+ background: var(--component-select-background);
387
+ border: var(--component-inputs-border-width) solid var(--component-select-border-color);
388
+ border-radius: var(--component-inputs-border-radius-outer);
389
+ box-shadow: var(--component-select-shadow, 0 2px 8px rgba(0,0,0,0.1));
390
+ max-height: 200px;
391
+ overflow-y: auto;
392
+ display: none;
393
+ }
394
+
395
+ .dropdown.open {
396
+ display: block;
397
+ }
398
+
399
+ .dropdown-item {
400
+ padding: var(--spacing-small, 6px);
401
+ cursor: pointer;
402
+ }
403
+
404
+ .dropdown-item:hover {
405
+ background: var(--component-select-hover-background);
406
+ }
407
+
408
+ .dropdown-item.selected {
409
+ background: var(--component-select-accent);
410
+ color: var(--component-select-accent-contrast);
411
+ }
412
+
413
+ .value-display {
414
+ flex: 1;
415
+ font-size: 1em;
416
+ color: inherit;
417
+ display: flex;
418
+ gap: var(--spacing-x-small);
419
+ }
420
+
421
+ .label-wrapper {
422
+ display: block;
423
+ }
424
+
425
+ .chevron-icon {
426
+ display: flex;
427
+ align-items: center;
428
+ justify-content: center;
429
+ margin-left: auto;
430
+ transition: transform 0.2s ease;
431
+ }
432
+
433
+ .chevron-icon svg {
434
+ transition: transform 0.2s ease;
435
+ transform-origin: center;
436
+ }
437
+
438
+ .select-container.open .chevron-icon svg {
439
+ transform: scaleY(-1);
440
+ }
441
+ `);
442
+
443
+ this.shadowRoot.adoptedStyleSheets = [sheet];
444
+ }
445
+
446
+ generateTemplate() {
447
+ const labelPosition = this.getAttribute("label-position") || "top";
448
+ const isLabelTop = labelPosition === "top";
449
+ const isInvalid = this.hasAttribute("invalid");
450
+ const isMulti = this.hasAttribute("multiple");
451
+
452
+ const valueSet = isMulti ? this.selectedValues : new Set([this.value]);
453
+
454
+ return `
455
+ <div class="select-wrapper">
456
+ ${isLabelTop ? '<div class="label-wrapper"><slot name="label"></slot></div>' : ""}
457
+ <div class="select-container ${isInvalid ? "is-invalid" : ""}" tabindex="0">
458
+ <div class="value-display">${this.getDisplayText()}</div>
459
+ <div class="chevron-icon" part="chevron-icon">
460
+ ${chevronDownLg}
461
+ </div>
462
+ </div>
463
+ ${!isLabelTop ? '<div class="label-wrapper"><slot name="label"></slot></div>' : ""}
464
+ <div class="dropdown" part="dropdown">
465
+ ${this.getOptions()
466
+ .map(
467
+ (opt) => `
468
+ <div class="dropdown-item ${valueSet.has(opt.value) ? "selected" : ""}" data-value="${opt.value}">
469
+ ${opt.label}
470
+ </div>
471
+ `,
472
+ )
473
+ .join("")}
474
+ </div>
475
+ </div>
476
+ `;
477
+ }
478
+ }
479
+
480
+ if (!customElements.get("y-select")) {
481
+ customElements.define("y-select", YumeSelect);
482
+ }
483
+
484
+ export { YumeSelect };