@waggylabs/yumekit 0.4.3-beta.40 → 0.4.3-beta.41
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/CHANGELOG.md +6 -0
- package/CONTRIBUTING.md +56 -0
- package/dist/components/y-breadcrumbs.js +12 -23
- package/dist/components/y-tabs/y-tabs.d.ts +6 -3
- package/dist/components/y-tabs.d.ts +6 -3
- package/dist/components/y-tabs.js +286 -42
- package/dist/index.js +79 -65
- package/dist/yumekit.min.js +1 -1
- package/llm.txt +26 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -42,6 +42,12 @@ Delete any empty sections before publishing.
|
|
|
42
42
|
- New `y-color` component — a form-associated color input with a trigger/popup pattern (like `<y-date>`). Renders a color swatch preview and value string in the trigger; opens a `<y-colorpicker>` popup on click. Supports `value`, `format`, `formats`, `show-alpha`, `placeholder`, `name`, `disabled`, `invalid`, `clearable`, `size`, and `label-position` attributes. Uses `ElementInternals` for `FormData` participation.
|
|
43
43
|
- New `y-banner` component — a full-width informational banner that renders in a semantic color with matching text. Supports `color` (`"base"` | `"primary"` | `"secondary"` | `"success"` | `"error"` | `"warning"` | `"help"`), `icon` attribute (or `icon` slot), `position` (`"push"` | `"overlap"`), `sticky` (fixed to viewport when overlapping), `dismissable` close button, `size` (`"small"` | `"medium"` | `"large"`), and an `action` slot for CTA elements.
|
|
44
44
|
- New `y-stack` component — a layout container for arranging child elements in rows, columns, grids, or masonry patterns. Supports `mode` (`"flex"` | `"grid"` | `"masonry"`), `direction`, `columns`, `gap` (maps to `--spacing-*` tokens), `wrap`, `align`, `justify`, and `responsive` attributes. Masonry mode uses JS absolute positioning with `ResizeObserver`. Responsive mode auto-collapses columns at configurable breakpoints.
|
|
45
|
+
- `y-tabs`: `leftIcon` and `rightIcon` properties on option objects — set a `<y-icon>` name directly in the options JSON to render icons without requiring extra child elements or named slots.
|
|
46
|
+
- `y-tabs`: `tab-content-{id}` slot — place any content (icons, badges, custom markup) inside the tab button itself by targeting this slot. Takes full precedence over `leftIcon`/`rightIcon` and the default label rendering.
|
|
47
|
+
|
|
48
|
+
### Deprecated
|
|
49
|
+
|
|
50
|
+
- `y-tabs`: `left-icon-{id}` and `right-icon-{id}` slots — these slots still function but emit a `console.warn` directing users to the `leftIcon`/`rightIcon` option properties or the `tab-content-{id}` slot. They will be removed before the release of version 1.0.
|
|
45
51
|
|
|
46
52
|
## [0.4.2] - 2026-04-07
|
|
47
53
|
|
package/CONTRIBUTING.md
CHANGED
|
@@ -36,6 +36,62 @@ Yumekit is authored in plain JavaScript. Please follow the conventions already p
|
|
|
36
36
|
- Keep components self-contained: styles live in the Shadow DOM, logic in the class, no shared global state.
|
|
37
37
|
- Run the linter before submitting: `npm run lint`.
|
|
38
38
|
|
|
39
|
+
## Component Authoring Guidelines
|
|
40
|
+
|
|
41
|
+
### Class structure
|
|
42
|
+
|
|
43
|
+
Every component class must have exactly four comment-delimited sections in this order — no more, no subdivisions:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
// Lifecycle
|
|
47
|
+
// Getters / Setters
|
|
48
|
+
// Public
|
|
49
|
+
// Private
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Methods within their subdivision must be listed alphabetically.
|
|
53
|
+
|
|
54
|
+
### Method style
|
|
55
|
+
|
|
56
|
+
Follow a **define → compute → return/apply** flow within each method where possible: gather inputs and state at the top, do the work in the middle, produce output at the end. Keep methods small and focused — if a method grows long or does multiple distinct things, extract a named helper. Minimize nesting by preferring early returns over deep `if/else` trees. Separate distinct logical units of work with blank lines. This ruleset allows humans and AI to have a predictable and human-readable code structure upon which to base future updates.
|
|
57
|
+
|
|
58
|
+
### DOM element creation
|
|
59
|
+
|
|
60
|
+
Use the `createElement` helper (imported as `_el`) from `src/modules/helpers.js` for all element creation inside components. Do not use manual `document.createElement` + `setAttribute` chains.
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
import { createElement as _el } from "../../modules/helpers.js";
|
|
64
|
+
|
|
65
|
+
const btn = _el("button", { role: "tab", "aria-label": label }, [labelText]);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Icons
|
|
69
|
+
|
|
70
|
+
Use `<y-icon name="...">` for all icons. Never inline SVG strings or constants. Import `y-icon.js` at the top of any component that renders icons.
|
|
71
|
+
|
|
72
|
+
### Slot patterns
|
|
73
|
+
|
|
74
|
+
Always render named slots unconditionally and place default/fallback content as **children of the slot element**. Never use `querySelector` to decide whether to create a slot. This breaks framework rendering (React, Vue, etc.) where children may arrive after the element upgrades.
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
// Correct
|
|
78
|
+
const slot = _el("slot", { name: "icon" });
|
|
79
|
+
slot.appendChild(defaultIcon);
|
|
80
|
+
parent.appendChild(slot);
|
|
81
|
+
|
|
82
|
+
// Wrong — slot is conditional on a render-time DOM query
|
|
83
|
+
if (this.querySelector('[slot="icon"]')) { ... }
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### New component checklist
|
|
87
|
+
|
|
88
|
+
Every new component requireschanges to the following: `README.md`, `CHANGELOG.md`, `reference.md`, `SKILL.md`, `react.d.ts`, `variables.css`, `.figma/variables.json`, entry in `llm.txt`, and a `y-*.stories.js` stories file.
|
|
89
|
+
|
|
90
|
+
### Testing
|
|
91
|
+
|
|
92
|
+
- Tests co-locate with the component source file.
|
|
93
|
+
- Use `sinon.createSandbox()` at the `describe` level with `afterEach(() => sandbox.restore())`.
|
|
94
|
+
|
|
39
95
|
## AI Assistance
|
|
40
96
|
|
|
41
97
|
AI tools can be helpful for brainstorming and prototyping, but they are not a substitute for human judgment and expertise.
|
|
@@ -395,25 +395,9 @@ class YumeBreadcrumbs extends HTMLElement {
|
|
|
395
395
|
part: parts.join(" "),
|
|
396
396
|
});
|
|
397
397
|
|
|
398
|
-
|
|
399
|
-
const hasIconSlot = !!this.querySelector(`[slot="${iconSlotName}"]`);
|
|
400
|
-
|
|
401
|
-
if (hasIconSlot) {
|
|
402
|
-
li.appendChild(
|
|
403
|
-
createElement("slot", { name: iconSlotName, class: "item-icon" }),
|
|
404
|
-
);
|
|
405
|
-
} else if (item.icon) {
|
|
406
|
-
li.appendChild(
|
|
407
|
-
createElement("y-icon", {
|
|
408
|
-
name: item.icon,
|
|
409
|
-
size: this._getIconSize(),
|
|
410
|
-
class: "item-icon",
|
|
411
|
-
}),
|
|
412
|
-
);
|
|
413
|
-
}
|
|
414
|
-
|
|
398
|
+
let linkEl;
|
|
415
399
|
if (isCurrent || !item.href) {
|
|
416
|
-
|
|
400
|
+
linkEl = createElement(
|
|
417
401
|
"span",
|
|
418
402
|
{
|
|
419
403
|
class: "link current-text",
|
|
@@ -422,9 +406,8 @@ class YumeBreadcrumbs extends HTMLElement {
|
|
|
422
406
|
},
|
|
423
407
|
[item.text],
|
|
424
408
|
);
|
|
425
|
-
li.appendChild(span);
|
|
426
409
|
} else {
|
|
427
|
-
|
|
410
|
+
linkEl = createElement(
|
|
428
411
|
"a",
|
|
429
412
|
{
|
|
430
413
|
class: "link",
|
|
@@ -433,14 +416,20 @@ class YumeBreadcrumbs extends HTMLElement {
|
|
|
433
416
|
},
|
|
434
417
|
[item.text],
|
|
435
418
|
);
|
|
436
|
-
|
|
437
|
-
anchor.addEventListener("click", (e) => {
|
|
419
|
+
linkEl.addEventListener("click", (e) => {
|
|
438
420
|
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
439
421
|
this._onNavigate(e, item.href);
|
|
440
422
|
});
|
|
423
|
+
}
|
|
441
424
|
|
|
442
|
-
|
|
425
|
+
const itemSlot = createElement("slot", { name: `${index}-item` });
|
|
426
|
+
if (item.icon) {
|
|
427
|
+
itemSlot.appendChild(
|
|
428
|
+
createElement("y-icon", { name: item.icon, size: this._getIconSize(), class: "item-icon" }),
|
|
429
|
+
);
|
|
443
430
|
}
|
|
431
|
+
itemSlot.appendChild(linkEl);
|
|
432
|
+
li.appendChild(itemSlot);
|
|
444
433
|
|
|
445
434
|
return li;
|
|
446
435
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
export class YumeTabs extends HTMLElement {
|
|
2
2
|
static get observedAttributes(): string[];
|
|
3
3
|
_activeTab: string;
|
|
4
|
+
_warnedSlots: Set<any>;
|
|
4
5
|
connectedCallback(): void;
|
|
5
6
|
attributeChangedCallback(name: any, oldVal: any, newVal: any): void;
|
|
6
7
|
set options(val: Array<any>);
|
|
7
|
-
/** @type {Array<Object>} Tab definitions
|
|
8
|
+
/** @type {Array<Object>} Tab definitions. Each object: `{ id, label, slot, disabled?, leftIcon?, rightIcon? }`. `leftIcon`/`rightIcon` are `y-icon` names rendered inside the tab button. Use the `tab-content-{id}` slot to supply fully custom tab button content instead. */
|
|
8
9
|
get options(): Array<any>;
|
|
9
10
|
set position(val: "top" | "bottom" | "left" | "right");
|
|
10
11
|
/** @type {"top"|"bottom"|"left"|"right"} Which edge the tab strip is placed on. */
|
|
@@ -18,8 +19,10 @@ export class YumeTabs extends HTMLElement {
|
|
|
18
19
|
*/
|
|
19
20
|
activateTab(id: string): void;
|
|
20
21
|
render(): void;
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
_appendDeprecatedIconSlot(parent: any, side: any, tabId: any): void;
|
|
23
|
+
_createIcon(name: any): HTMLElement;
|
|
24
|
+
_createPanel(slotName: any): HTMLElement;
|
|
25
|
+
_createTabButton(tab: any): HTMLElement;
|
|
23
26
|
_findSiblingButton(buttons: any, fromIndex: any, direction: any): any;
|
|
24
27
|
_getStyles(): string;
|
|
25
28
|
_handleTabKeydown(e: any, buttons: any): void;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
export class YumeTabs extends HTMLElement {
|
|
2
2
|
static get observedAttributes(): string[];
|
|
3
3
|
_activeTab: string;
|
|
4
|
+
_warnedSlots: Set<any>;
|
|
4
5
|
connectedCallback(): void;
|
|
5
6
|
attributeChangedCallback(name: any, oldVal: any, newVal: any): void;
|
|
6
7
|
set options(val: Array<any>);
|
|
7
|
-
/** @type {Array<Object>} Tab definitions
|
|
8
|
+
/** @type {Array<Object>} Tab definitions. Each object: `{ id, label, slot, disabled?, leftIcon?, rightIcon? }`. `leftIcon`/`rightIcon` are `y-icon` names rendered inside the tab button. Use the `tab-content-{id}` slot to supply fully custom tab button content instead. */
|
|
8
9
|
get options(): Array<any>;
|
|
9
10
|
set position(val: "top" | "bottom" | "left" | "right");
|
|
10
11
|
/** @type {"top"|"bottom"|"left"|"right"} Which edge the tab strip is placed on. */
|
|
@@ -18,8 +19,10 @@ export class YumeTabs extends HTMLElement {
|
|
|
18
19
|
*/
|
|
19
20
|
activateTab(id: string): void;
|
|
20
21
|
render(): void;
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
_appendDeprecatedIconSlot(parent: any, side: any, tabId: any): void;
|
|
23
|
+
_createIcon(name: any): HTMLElement;
|
|
24
|
+
_createPanel(slotName: any): HTMLElement;
|
|
25
|
+
_createTabButton(tab: any): HTMLElement;
|
|
23
26
|
_findSiblingButton(buttons: any, fromIndex: any, direction: any): any;
|
|
24
27
|
_getStyles(): string;
|
|
25
28
|
_handleTabKeydown(e: any, buttons: any): void;
|
|
@@ -1,3 +1,222 @@
|
|
|
1
|
+
import { getIcon } from '../../icons/registry.js';
|
|
2
|
+
import { createElement } from '../../modules/helpers.js';
|
|
3
|
+
|
|
4
|
+
// Allowlist-based SVG sanitizer — only known-safe elements and attributes are kept.
|
|
5
|
+
const ALLOWED_ELEMENTS = new Set([
|
|
6
|
+
"svg", "g", "path", "circle", "ellipse", "rect", "line", "polyline",
|
|
7
|
+
"polygon", "text", "tspan", "defs", "clippath", "mask", "lineargradient",
|
|
8
|
+
"radialgradient", "stop", "symbol", "title", "desc", "metadata",
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
const ALLOWED_ATTRS = new Set([
|
|
12
|
+
"viewbox", "xmlns", "fill", "stroke", "stroke-width", "stroke-linecap",
|
|
13
|
+
"stroke-linejoin", "stroke-dasharray", "stroke-dashoffset", "stroke-miterlimit",
|
|
14
|
+
"stroke-opacity", "fill-opacity", "fill-rule", "clip-rule", "opacity",
|
|
15
|
+
"d", "cx", "cy", "r", "rx", "ry", "x", "x1", "x2", "y", "y1", "y2",
|
|
16
|
+
"width", "height", "points", "transform", "id", "class", "clip-path", "mask",
|
|
17
|
+
"offset", "stop-color", "stop-opacity", "gradient-units", "gradienttransform",
|
|
18
|
+
"gradientunits", "spreadmethod", "patternunits", "patterntransform",
|
|
19
|
+
"font-size", "font-family", "font-weight", "text-anchor", "dominant-baseline",
|
|
20
|
+
"alignment-baseline", "dx", "dy", "rotate", "textlength", "lengthadjust",
|
|
21
|
+
"display", "visibility", "color", "vector-effect",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
function sanitizeSvg(raw) {
|
|
25
|
+
if (!raw) return "";
|
|
26
|
+
const doc = new DOMParser().parseFromString(raw, "image/svg+xml");
|
|
27
|
+
const svg = doc.querySelector("svg");
|
|
28
|
+
if (!svg) return "";
|
|
29
|
+
|
|
30
|
+
const walk = (el) => {
|
|
31
|
+
for (const child of [...el.children]) {
|
|
32
|
+
if (!ALLOWED_ELEMENTS.has(child.tagName.toLowerCase())) {
|
|
33
|
+
child.remove();
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
for (const attr of [...child.attributes]) {
|
|
37
|
+
if (!ALLOWED_ATTRS.has(attr.name.toLowerCase())) {
|
|
38
|
+
child.removeAttribute(attr.name);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
walk(child);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
for (const attr of [...svg.attributes]) {
|
|
46
|
+
if (!ALLOWED_ATTRS.has(attr.name.toLowerCase())) {
|
|
47
|
+
svg.removeAttribute(attr.name);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
walk(svg);
|
|
51
|
+
return svg.outerHTML;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Cache sanitized SVG markup per icon name to avoid repeated DOMParser + DOM-walk
|
|
55
|
+
// on every render. The cache is naturally bounded by the number of registered icons.
|
|
56
|
+
const sanitizedSvgCache = new Map();
|
|
57
|
+
|
|
58
|
+
function getCachedSvg(name) {
|
|
59
|
+
if (sanitizedSvgCache.has(name)) return sanitizedSvgCache.get(name);
|
|
60
|
+
const result = sanitizeSvg(getIcon(name));
|
|
61
|
+
sanitizedSvgCache.set(name, result);
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
class YumeIcon extends HTMLElement {
|
|
66
|
+
static get observedAttributes() {
|
|
67
|
+
return ["name", "size", "color", "label", "weight"];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// -------------------------------------------------------------------------
|
|
71
|
+
// Lifecycle
|
|
72
|
+
// -------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
constructor() {
|
|
75
|
+
super();
|
|
76
|
+
this.attachShadow({ mode: "open" });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
connectedCallback() {
|
|
80
|
+
this.render();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
attributeChangedCallback(name, oldVal, newVal) {
|
|
84
|
+
if (oldVal === newVal) return;
|
|
85
|
+
this.render();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// -------------------------------------------------------------------------
|
|
89
|
+
// Getters / Setters
|
|
90
|
+
// -------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/** Color theme: "base" | "primary" | "secondary" | "success" | "warning" | "error" | "help". */
|
|
93
|
+
get color() { return this.getAttribute("color") || ""; }
|
|
94
|
+
set color(val) {
|
|
95
|
+
if (val) this.setAttribute("color", val);
|
|
96
|
+
else this.removeAttribute("color");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Accessible label for the icon. When set, the icon gets role="img". */
|
|
100
|
+
get label() { return this.getAttribute("label") || ""; }
|
|
101
|
+
set label(val) {
|
|
102
|
+
if (val) this.setAttribute("label", val);
|
|
103
|
+
else this.removeAttribute("label");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The registered icon name to display. */
|
|
107
|
+
get name() { return this.getAttribute("name") || ""; }
|
|
108
|
+
set name(val) { this.setAttribute("name", val); }
|
|
109
|
+
|
|
110
|
+
/** Icon size: "x-small" | "small" | "medium" | "large" | "x-large" (default "medium"). */
|
|
111
|
+
get size() { return this.getAttribute("size") || "medium"; }
|
|
112
|
+
set size(val) { this.setAttribute("size", val); }
|
|
113
|
+
|
|
114
|
+
/** Stroke weight: "thin" | "regular" | "thick". */
|
|
115
|
+
get weight() { return this.getAttribute("weight") || "regular"; }
|
|
116
|
+
set weight(val) {
|
|
117
|
+
if (val) this.setAttribute("weight", val);
|
|
118
|
+
else this.removeAttribute("weight");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// -------------------------------------------------------------------------
|
|
122
|
+
// Public
|
|
123
|
+
// -------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
render() {
|
|
126
|
+
const svg = getCachedSvg(this.name);
|
|
127
|
+
const sizeVal = this._getSize(this.size);
|
|
128
|
+
const colorVal = this.color ? this._getColor(this.color) : "inherit";
|
|
129
|
+
const weightVal = this._getWeight(this.weight);
|
|
130
|
+
|
|
131
|
+
this._updateAria();
|
|
132
|
+
|
|
133
|
+
this.shadowRoot.innerHTML = `
|
|
134
|
+
<style>
|
|
135
|
+
:host {
|
|
136
|
+
display: inline-flex;
|
|
137
|
+
align-items: center;
|
|
138
|
+
justify-content: center;
|
|
139
|
+
width: ${sizeVal};
|
|
140
|
+
height: ${sizeVal};
|
|
141
|
+
color: ${colorVal};
|
|
142
|
+
line-height: 0;
|
|
143
|
+
}
|
|
144
|
+
.icon-wrapper svg {
|
|
145
|
+
width: 100%;
|
|
146
|
+
height: 100%;
|
|
147
|
+
}
|
|
148
|
+
${this._getWeightCSS(weightVal)}
|
|
149
|
+
</style>
|
|
150
|
+
<span class="icon-wrapper" part="icon">${svg}</span>
|
|
151
|
+
`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// -------------------------------------------------------------------------
|
|
155
|
+
// Private
|
|
156
|
+
// -------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
_getColor(color) {
|
|
159
|
+
const map = {
|
|
160
|
+
base: "var(--base-content--, #f7f7fa)",
|
|
161
|
+
primary: "var(--primary-content--, #0576ff)",
|
|
162
|
+
secondary: "var(--secondary-content--, #04b8b8)",
|
|
163
|
+
success: "var(--success-content--, #2dba73)",
|
|
164
|
+
warning: "var(--warning-content--, #d17f04)",
|
|
165
|
+
error: "var(--error-content--, #b80421)",
|
|
166
|
+
help: "var(--help-content--, #5405ff)",
|
|
167
|
+
};
|
|
168
|
+
if (map[color]) return map[color];
|
|
169
|
+
if (color && (color.startsWith("#") || color.startsWith("rgb") || color.startsWith("hsl"))) {
|
|
170
|
+
return color;
|
|
171
|
+
}
|
|
172
|
+
return map.base;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
_getSize(size) {
|
|
176
|
+
const map = {
|
|
177
|
+
"x-small": "var(--component-icon-size-x-small, 10px)",
|
|
178
|
+
small: "var(--component-icon-size-small, 14px)",
|
|
179
|
+
medium: "var(--component-icon-size-medium, 18px)",
|
|
180
|
+
large: "var(--component-icon-size-large, 22px)",
|
|
181
|
+
"x-large": "var(--component-icon-size-x-large, 28px)",
|
|
182
|
+
};
|
|
183
|
+
return map[size] || map.medium;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
_getWeight(weight) {
|
|
187
|
+
const map = {
|
|
188
|
+
"x-thin": "1",
|
|
189
|
+
thin: "1.5",
|
|
190
|
+
regular: "2",
|
|
191
|
+
thick: "2.5",
|
|
192
|
+
"x-thick": "3",
|
|
193
|
+
};
|
|
194
|
+
return map[weight] || "";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
_getWeightCSS(weightVal) {
|
|
198
|
+
if (!weightVal) return "";
|
|
199
|
+
return `.icon-wrapper svg,
|
|
200
|
+
.icon-wrapper svg * { stroke-width: ${weightVal} !important; }`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
_updateAria() {
|
|
204
|
+
if (this.label) {
|
|
205
|
+
this.setAttribute("role", "img");
|
|
206
|
+
this.setAttribute("aria-label", this.label);
|
|
207
|
+
this.removeAttribute("aria-hidden");
|
|
208
|
+
} else {
|
|
209
|
+
this.setAttribute("aria-hidden", "true");
|
|
210
|
+
this.removeAttribute("role");
|
|
211
|
+
this.removeAttribute("aria-label");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!customElements.get("y-icon")) {
|
|
217
|
+
customElements.define("y-icon", YumeIcon);
|
|
218
|
+
}
|
|
219
|
+
|
|
1
220
|
class YumeTabs extends HTMLElement {
|
|
2
221
|
static get observedAttributes() {
|
|
3
222
|
return ["options", "size", "position"];
|
|
@@ -11,6 +230,7 @@ class YumeTabs extends HTMLElement {
|
|
|
11
230
|
super();
|
|
12
231
|
this.attachShadow({ mode: "open" });
|
|
13
232
|
this._activeTab = "";
|
|
233
|
+
this._warnedSlots = new Set();
|
|
14
234
|
}
|
|
15
235
|
|
|
16
236
|
connectedCallback() {
|
|
@@ -20,16 +240,16 @@ class YumeTabs extends HTMLElement {
|
|
|
20
240
|
}
|
|
21
241
|
|
|
22
242
|
attributeChangedCallback(name, oldVal, newVal) {
|
|
23
|
-
if (
|
|
24
|
-
|
|
25
|
-
|
|
243
|
+
if (oldVal === newVal) return;
|
|
244
|
+
if (name === "options") this._warnedSlots.clear();
|
|
245
|
+
this.render();
|
|
26
246
|
}
|
|
27
247
|
|
|
28
248
|
// -------------------------------------------------------------------------
|
|
29
249
|
// Getters / Setters
|
|
30
250
|
// -------------------------------------------------------------------------
|
|
31
251
|
|
|
32
|
-
/** @type {Array<Object>} Tab definitions
|
|
252
|
+
/** @type {Array<Object>} Tab definitions. Each object: `{ id, label, slot, disabled?, leftIcon?, rightIcon? }`. `leftIcon`/`rightIcon` are `y-icon` names rendered inside the tab button. Use the `tab-content-{id}` slot to supply fully custom tab button content instead. */
|
|
33
253
|
get options() {
|
|
34
254
|
try {
|
|
35
255
|
return JSON.parse(this.getAttribute("options") || "[]");
|
|
@@ -88,10 +308,7 @@ class YumeTabs extends HTMLElement {
|
|
|
88
308
|
style.textContent = this._getStyles();
|
|
89
309
|
this.shadowRoot.appendChild(style);
|
|
90
310
|
|
|
91
|
-
const tablist =
|
|
92
|
-
tablist.className = "tablist";
|
|
93
|
-
tablist.setAttribute("role", "tablist");
|
|
94
|
-
tablist.setAttribute("part", "tablist");
|
|
311
|
+
const tablist = createElement("div", { class: "tablist", role: "tablist", part: "tablist" });
|
|
95
312
|
tabs.forEach((tab) => tablist.appendChild(this._createTabButton(tab)));
|
|
96
313
|
this.shadowRoot.appendChild(tablist);
|
|
97
314
|
|
|
@@ -104,17 +321,38 @@ class YumeTabs extends HTMLElement {
|
|
|
104
321
|
// Private
|
|
105
322
|
// -------------------------------------------------------------------------
|
|
106
323
|
|
|
324
|
+
_appendDeprecatedIconSlot(parent, side, tabId) {
|
|
325
|
+
const slotName = `${side}-icon-${tabId}`;
|
|
326
|
+
|
|
327
|
+
if (!this.querySelector(`[slot="${slotName}"]`)) return;
|
|
328
|
+
|
|
329
|
+
if (!this._warnedSlots.has(slotName)) {
|
|
330
|
+
this._warnedSlots.add(slotName);
|
|
331
|
+
// eslint-disable-next-line no-console
|
|
332
|
+
console.warn(
|
|
333
|
+
`[y-tabs] The "${slotName}" slot is deprecated. ` +
|
|
334
|
+
`Use the ${side}Icon property on the tab options object instead, ` +
|
|
335
|
+
`or use the "tab-content-${tabId}" slot for custom content.`
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
parent.appendChild(createElement("slot", { name: slotName, class: "icon-slot" }));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
_createIcon(name) {
|
|
343
|
+
return createElement("y-icon", { name, size: this.size });
|
|
344
|
+
}
|
|
345
|
+
|
|
107
346
|
_createPanel(slotName) {
|
|
108
|
-
const panel =
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
347
|
+
const panel = createElement("div", {
|
|
348
|
+
class: "tabpanel",
|
|
349
|
+
id: `panel-${this._activeTab}`,
|
|
350
|
+
role: "tabpanel",
|
|
351
|
+
part: "content",
|
|
352
|
+
"aria-labelledby": `tab-${this._activeTab}`,
|
|
353
|
+
});
|
|
114
354
|
|
|
115
|
-
|
|
116
|
-
contentSlot.name = slotName;
|
|
117
|
-
panel.appendChild(contentSlot);
|
|
355
|
+
panel.appendChild(createElement("slot", { name: slotName }));
|
|
118
356
|
|
|
119
357
|
return panel;
|
|
120
358
|
}
|
|
@@ -122,35 +360,36 @@ class YumeTabs extends HTMLElement {
|
|
|
122
360
|
_createTabButton(tab) {
|
|
123
361
|
const isActive = tab.id === this._activeTab;
|
|
124
362
|
const isDisabled = !!tab.disabled;
|
|
125
|
-
const btn = document.createElement("button");
|
|
126
363
|
|
|
127
|
-
btn
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
364
|
+
const btn = createElement("button", {
|
|
365
|
+
id: `tab-${tab.id}`,
|
|
366
|
+
role: "tab",
|
|
367
|
+
part: "tab",
|
|
368
|
+
"aria-label": tab.label,
|
|
369
|
+
"aria-selected": String(isActive),
|
|
370
|
+
"aria-controls": `panel-${tab.id}`,
|
|
371
|
+
"aria-disabled": String(isDisabled),
|
|
372
|
+
});
|
|
133
373
|
|
|
134
374
|
if (isDisabled) btn.disabled = true;
|
|
135
375
|
btn.tabIndex = isActive && !isDisabled ? 0 : -1;
|
|
136
376
|
btn.dataset.id = tab.id;
|
|
137
377
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
378
|
+
const contentSlot = createElement("slot", { name: `tab-content-${tab.id}` });
|
|
379
|
+
btn.appendChild(contentSlot);
|
|
380
|
+
|
|
381
|
+
if (tab.leftIcon) {
|
|
382
|
+
contentSlot.appendChild(this._createIcon(tab.leftIcon));
|
|
383
|
+
} else {
|
|
384
|
+
this._appendDeprecatedIconSlot(contentSlot, "left", tab.id);
|
|
143
385
|
}
|
|
144
386
|
|
|
145
|
-
|
|
146
|
-
labelSpan.textContent = tab.label;
|
|
147
|
-
btn.appendChild(labelSpan);
|
|
387
|
+
contentSlot.appendChild(createElement("span", {}, [tab.label]));
|
|
148
388
|
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
btn.appendChild(rightSlot);
|
|
389
|
+
if (tab.rightIcon) {
|
|
390
|
+
contentSlot.appendChild(this._createIcon(tab.rightIcon));
|
|
391
|
+
} else {
|
|
392
|
+
this._appendDeprecatedIconSlot(contentSlot, "right", tab.id);
|
|
154
393
|
}
|
|
155
394
|
|
|
156
395
|
return btn;
|
|
@@ -253,13 +492,20 @@ class YumeTabs extends HTMLElement {
|
|
|
253
492
|
|
|
254
493
|
_handleTabKeydown(e, buttons) {
|
|
255
494
|
const idx = buttons.indexOf(e.currentTarget);
|
|
495
|
+
|
|
256
496
|
if (e.key === "ArrowRight") {
|
|
257
497
|
e.preventDefault();
|
|
258
498
|
this._findSiblingButton(buttons, idx, 1)?.focus();
|
|
259
|
-
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (e.key === "ArrowLeft") {
|
|
260
503
|
e.preventDefault();
|
|
261
504
|
this._findSiblingButton(buttons, idx, -1)?.focus();
|
|
262
|
-
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
263
509
|
e.preventDefault();
|
|
264
510
|
this.activateTab(e.currentTarget.dataset.id);
|
|
265
511
|
}
|
|
@@ -277,9 +523,7 @@ class YumeTabs extends HTMLElement {
|
|
|
277
523
|
buttons.forEach((button) => {
|
|
278
524
|
if (button.disabled) return;
|
|
279
525
|
button.addEventListener("click", () => this.activateTab(button.dataset.id));
|
|
280
|
-
button.addEventListener("keydown", (e) =>
|
|
281
|
-
this._handleTabKeydown(e, buttons);
|
|
282
|
-
});
|
|
526
|
+
button.addEventListener("keydown", (e) => this._handleTabKeydown(e, buttons));
|
|
283
527
|
});
|
|
284
528
|
}
|
|
285
529
|
}
|