lifecycle-timeline 1.0.1 → 1.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.
package/README.md CHANGED
@@ -7,7 +7,8 @@ A premium, interactive Vanilla JS component for visualizing product lifecycles,
7
7
 
8
8
  ## ✨ Features
9
9
 
10
- - **Smart Filtering**: Real-time search to filter versions.
10
+ - **Interactive Table**: A clear data table between the filter and tracks for quick reference.
11
+ - **Smart Filtering**: Real-time search to filter versions across both table and timeline.
11
12
  - **Dark Mode**: Native support with a persistent toggle.
12
13
  - **Rich Legend**: Detailed explanation of support states.
13
14
  - **Responsive Design**: Works on all screen sizes with horizontal scroll support.
@@ -58,47 +59,60 @@ new Timeline('timeline-root', data, { visibleCount: 3 });
58
59
  </script>
59
60
  ```
60
61
 
61
- ## ⚙️ Configuration
62
+ ## ⚙️ Configuration & API
62
63
 
63
64
  ### Constructor
64
65
  `new Timeline(elementId, data, options)`
65
66
 
66
- | Argument | Type | Description |
67
- | :--- | :--- | :--- |
68
- | `elementId` | `string` | The ID of the container element. |
69
- | `data` | `Array` | List of version objects. |
70
- | `options` | `Object` | Optional configuration. |
67
+ #### `options` Object
71
68
 
72
- ### Data Schema
73
- Each version object in the `data` array should follow this structure:
74
- - `version`: (String) Version name/number.
75
- - `ossStart`: (String) Start date of OSS support (YYYY-MM-DD).
76
- - `ossEnd`: (String) End date of OSS support (YYYY-MM-DD).
77
- - `enterpriseEnd`: (String) End date of Enterprise support (YYYY-MM-DD).
78
- - `releaseNotesUrl`: (String, Optional) Link to release notes.
69
+ | Property | Type | Default | Description |
70
+ | :--- | :--- | :--- | :--- |
71
+ | `visibleCount` | `number` | `3` | Initial versions shown before "Show More" appears. |
72
+ | `locale` | `string` | `auto` | UI language (`'en'`, `'fr'`). |
73
+ | `i18n` | `object` | `{}` | Custom translations or new languages. |
74
+ | `showTable` | `boolean` | `true` | Shows/hides the summary data table. |
79
75
 
80
- ### Options
81
- - `visibleCount`: (Number, default: `3`) Number of versions to show before the "Show More" button appears.
82
- - `locale`: (String, default: browser detect) Preferred language for the UI. Supported: `'en'`, `'fr'`.
76
+ ### 🌐 Internationalization (i18n)
83
77
 
84
- ## 🎨 Theming
85
- The component uses CSS variables for easy customization. You can override them in your own CSS:
78
+ You can easily override existing labels or add new languages:
79
+
80
+ ```javascript
81
+ new Timeline('timeline-root', data, {
82
+ locale: 'fr',
83
+ i18n: {
84
+ fr: {
85
+ filter: "Rechercher une version...",
86
+ more: "Afficher {n} de plus"
87
+ }
88
+ }
89
+ });
90
+ ```
91
+
92
+ ### 🛡️ Data Validation
93
+ The component automatically validates your data. If required fields (`version`, `ossStart`, `ossEnd`) are missing or if date formats are invalid, a warning is logged in the browser console.
86
94
 
95
+ ## 🎨 Theming
96
+ The component uses CSS variables for easy customization:
87
97
  ```css
88
98
  :root {
89
- --accent-oss: #99e67d; /* Color for OSS support bars */
90
- --accent-ent: #ffe88e; /* Color for Enterprise support bars */
91
- --current-date: #086dc3; /* Color for the current date line */
92
- --bg-primary: #ffffff; /* Light mode background */
93
- --text-primary: #1e293b; /* Light mode text */
99
+ --accent-oss: #99e67d; /* Community support color */
100
+ --accent-ent: #ffe88e; /* Enterprise support color */
101
+ --current-date: #086dc3; /* Today's indicator color */
94
102
  }
95
103
  ```
96
104
 
97
- ## 🛠 Development
105
+ ## 🧪 Testing
106
+ We use **Vitest** for unit testing.
107
+ ```bash
108
+ npm test
109
+ ```
98
110
 
99
- 1. Install dependencies: `npm install`
100
- 2. Start development server: `npm run dev`
101
- 3. Build for production: `npm run build`
111
+ ## 🛠 Development
112
+ 1. `npm install`
113
+ 2. `npm run dev`
114
+ 3. `npm run build`
115
+ 4. `npm run release` (Build + Publish)
102
116
 
103
117
  ## 📄 License
104
118
  MIT © Eric REBOISSON
package/dist/timeline.js CHANGED
@@ -11,7 +11,11 @@ const u = {
11
11
  ossDesc: "Free security updates and bugfixes.",
12
12
  entDesc: "Expert support during OSS plus extended support after EOL.",
13
13
  eolDesc: "End of life. No further updates are provided.",
14
- today: "Today's date: {date}"
14
+ today: "Today's date: {date}",
15
+ branch: "Branch",
16
+ initial: "Initial Release",
17
+ ossEnd: "End OSS",
18
+ entEnd: "End Enterprise *"
15
19
  },
16
20
  fr: {
17
21
  filter: "Filtrer les versions...",
@@ -25,10 +29,14 @@ const u = {
25
29
  ossDesc: "Mises à jour de sécurité et corrections de bugs gratuites.",
26
30
  entDesc: "Support pendant la période OSS plus support étendu après.",
27
31
  eolDesc: "Version en fin de vie. Plus de mises à jour.",
28
- today: "Date du jour : {date}"
32
+ today: "Date du jour : {date}",
33
+ branch: "Version",
34
+ initial: "Sortie initiale",
35
+ ossEnd: "Fin OSS",
36
+ entEnd: "Fin Entreprise *"
29
37
  }
30
38
  };
31
- class v {
39
+ class b {
32
40
  /**
33
41
  * Creates an instance of Timeline.
34
42
  * @param {string} elementId - The ID of the root element.
@@ -38,16 +46,16 @@ class v {
38
46
  * @param {string} [options.locale] - Language code (e.g., 'en', 'fr').
39
47
  * @param {Object} [options.i18n] - Custom translations to merge or override.
40
48
  */
41
- constructor(t, e, i = {}) {
42
- this.root = document.getElementById(t), this.root && (this.options = i, this.visibleCount = i.visibleCount || 3, this.isExpanded = !1, this.theme = "light", this.filterText = "", this.translations = { ...u, ...i.i18n || {} }, this.locale = i.locale || this.detectLanguage(), this.setupBaseLayout(), this.updateData(e));
49
+ constructor(e, i, s = {}) {
50
+ this.root = document.getElementById(e), this.root && (this.options = s, this.visibleCount = s.visibleCount || 3, this.showTable = s.showTable !== !1, this.isExpanded = !1, this.isTableExpanded = !1, this.theme = "light", this.filterText = "", this.translations = { ...u, ...s.i18n || {} }, this.locale = s.locale || this.detectLanguage(), this.setupBaseLayout(), this.updateData(i));
43
51
  }
44
52
  /**
45
53
  * Detects the browser language.
46
54
  * @returns {string} The detected language code or 'en'.
47
55
  */
48
56
  detectLanguage() {
49
- const t = (navigator.language || "en").split("-")[0];
50
- return this.translations[t] ? t : "en";
57
+ const e = (navigator.language || "en").split("-")[0];
58
+ return this.translations[e] ? e : "en";
51
59
  }
52
60
  /**
53
61
  * Translates a key based on the current locale.
@@ -55,22 +63,36 @@ class v {
55
63
  * @param {Object} [params={}] - Parameters to replace in the translation string.
56
64
  * @returns {string} The translated string.
57
65
  */
58
- t(t, e = {}) {
59
- let i = (this.translations[this.locale] || this.translations.en)[t] || t;
60
- return Object.keys(e).forEach((s) => i = i.replace(`{${s}}`, e[s])), i;
66
+ t(e, i = {}) {
67
+ let s = (this.translations[this.locale] || this.translations.en)[e] || e;
68
+ return Object.keys(i).forEach((t) => s = s.replace(`{${t}}`, i[t])), s;
61
69
  }
62
70
  /**
63
71
  * Sets up the initial layout of the timeline.
64
72
  */
65
73
  setupBaseLayout() {
66
- this.root.innerHTML = "", this.root.setAttribute("role", "application"), this.root.setAttribute("aria-label", "Product Lifecycle Timeline"), this.renderToolbar(), this.renderThemeToggle(), this.wrapper = this.el("div", "timeline-wrapper", this.root), this.wrapper.setAttribute("role", "grid"), this.wrapper.setAttribute("aria-readonly", "true"), this.axis = this.el("div", "timeline-axis", this.wrapper), this.axis.setAttribute("role", "row"), this.tracks = this.el("div", "timeline-tracks", this.wrapper), this.legendContainer = this.el("div", "timeline-legend-container", this.wrapper);
74
+ this.root.innerHTML = "", this.root.setAttribute("role", "application"), this.root.setAttribute("aria-label", "Product Lifecycle Timeline"), this.renderToolbar(), this.renderThemeToggle(), this.showTable && (this.tableContainer = this.el("div", "timeline-table-container", this.root)), this.wrapper = this.el("div", "timeline-wrapper", this.root), this.wrapper.setAttribute("role", "grid"), this.wrapper.setAttribute("aria-readonly", "true"), this.axis = this.el("div", "timeline-axis", this.wrapper), this.axis.setAttribute("role", "row"), this.tracks = this.el("div", "timeline-tracks", this.wrapper), this.legendContainer = this.el("div", "timeline-legend-container", this.wrapper);
67
75
  }
68
76
  /**
69
77
  * Updates the timeline data and re-renders.
70
78
  * @param {Array<Object>} newData - The new data array.
71
79
  */
72
- updateData(t) {
73
- this.data = (t || []).filter((e) => e), this.calculateTimeRange(), this.render();
80
+ updateData(e) {
81
+ this.data = this.validateData(e || []), this.calculateTimeRange(), this.render();
82
+ }
83
+ /**
84
+ * Validates the input data.
85
+ * @param {Array<Object>} data - The data array to validate.
86
+ * @returns {Array<Object>} The validated and filtered data array.
87
+ */
88
+ validateData(e) {
89
+ return e.filter((i, s) => {
90
+ const n = ["version", "ossStart", "ossEnd"].filter((l) => !i[l]);
91
+ if (n.length > 0)
92
+ return console.warn(`[Timeline] Missing fields for item at index ${s}: ${n.join(", ")}`), !1;
93
+ const a = ["ossStart", "ossEnd", "enterpriseEnd"].filter((l) => i[l]).filter((l) => isNaN(new Date(i[l]).getTime()));
94
+ return a.length > 0 ? (console.warn(`[Timeline] Invalid date format for item "${i.version}": ${a.join(", ")}`), !1) : !0;
95
+ });
74
96
  }
75
97
  /**
76
98
  * Calculates the min and max years based on the data.
@@ -80,17 +102,62 @@ class v {
80
102
  this.minYear = this.currentDate.getFullYear() - 1, this.maxYear = this.currentDate.getFullYear() + 3;
81
103
  return;
82
104
  }
83
- const t = this.data.flatMap((e) => [
84
- new Date(e.ossStart),
85
- new Date(e.enterpriseEnd || e.ossEnd)
105
+ const e = this.data.flatMap((i) => [
106
+ new Date(i.ossStart),
107
+ new Date(i.enterpriseEnd || i.ossEnd)
86
108
  ]);
87
- this.minYear = new Date(Math.min(...t)).getFullYear(), this.maxYear = new Date(Math.max(...t)).getFullYear();
109
+ this.minYear = new Date(Math.min(...e)).getFullYear(), this.maxYear = new Date(Math.max(...e)).getFullYear();
110
+ }
111
+ /**
112
+ * Renders the data table.
113
+ */
114
+ renderTable() {
115
+ const e = this.el("table", "timeline-table", this.tableContainer);
116
+ e.setAttribute("aria-label", "Project support");
117
+ const i = this.el("thead", "", e), s = this.el("tr", "", i);
118
+ [this.t("branch"), this.t("initial"), this.t("ossEnd"), this.t("entEnd")].forEach((t) => {
119
+ this.el("th", "", s).textContent = t;
120
+ }), this.tableBody = this.el("tbody", "", e), this.tableRows = this.data.map((t) => this.createTableRow(t)), this.tableToggleContainer = this.el("div", "timeline-table-toggle", this.tableContainer), this.updateTableVisibility();
121
+ }
122
+ /**
123
+ * Creates a row for the data table.
124
+ * @param {Object} item - Version data.
125
+ */
126
+ createTableRow(e) {
127
+ const i = this.el("tr", "", this.tableBody), s = Date.now(), t = new Date(e.ossStart).getTime(), n = new Date(e.ossEnd).getTime(), o = e.enterpriseEnd ? new Date(e.enterpriseEnd).getTime() : n, a = s >= t && s <= n ? "status-oss" : s > n && s <= o ? "status-ent" : s > o ? "status-expired" : "", l = this.el("td", "", i);
128
+ let h = `<span class="table-badge ${a}">${e.versionOriginal || e.version}</span>`;
129
+ e.releaseNotesUrl ? l.innerHTML = `<a href="${e.releaseNotesUrl}" target="_blank" class="table-version-link">${h}</a>` : l.innerHTML = h;
130
+ const r = this.el("td", "", i);
131
+ r.textContent = e.ossStart, s > t && (r.className = "past-date");
132
+ const c = this.el("td", "", i);
133
+ c.textContent = e.ossEnd, s > n && (c.className = "past-date");
134
+ const d = this.el("td", "", i);
135
+ return d.textContent = e.enterpriseEnd || e.ossEnd, s > o && (d.className = "past-date"), { el: i, version: e.version.toLowerCase(), versionOriginal: e.versionOriginal || e.version };
136
+ }
137
+ /**
138
+ * Updates table visibility based on filter and visibleCount.
139
+ */
140
+ updateTableVisibility() {
141
+ const e = this.tableRows.filter((t) => t.version.includes(this.filterText));
142
+ this.tableRows.forEach((t) => {
143
+ const n = t.el.querySelector(".table-badge");
144
+ n.innerHTML = this.highlight(t.versionOriginal || t.version), t.el.classList.remove("row-visible"), t.el.classList.add("row-hidden");
145
+ });
146
+ const i = this.filterText === "" && e.length > this.visibleCount;
147
+ if ((i && !this.isTableExpanded ? e.slice(0, this.visibleCount) : e).forEach((t) => {
148
+ t.el.classList.remove("row-hidden"), t.el.classList.add("row-visible");
149
+ }), this.tableToggleContainer.innerHTML = "", i) {
150
+ const t = this.el("button", "timeline-toggle-btn table-toggle", this.tableToggleContainer), n = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="${this.isTableExpanded ? "18 15 12 9 6 15" : "6 9 12 15 18 9"}"></polyline></svg>`;
151
+ t.innerHTML = this.isTableExpanded ? `${this.t("less")} ${n}` : `${this.t("more", { n: e.length - this.visibleCount })} ${n}`, t.onclick = () => {
152
+ this.isTableExpanded = !this.isTableExpanded, this.updateTableVisibility();
153
+ };
154
+ }
88
155
  }
89
156
  /**
90
157
  * Renders the entire timeline.
91
158
  */
92
159
  render() {
93
- this.axis.innerHTML = "", this.tracks.innerHTML = "", this.legendContainer.innerHTML = "", this.renderAxis(), this.grid = this.el("div", "grid-lines-container", this.tracks), this.indicators = this.el("div", "indicators-container", this.tracks), this.renderGrid(), this.renderCurrentDateLine(), this.rows = this.data.map((t) => this.createRow(t)), this.toggleContainer = this.el("div", "timeline-more-toggle", this.tracks, {
160
+ this.showTable && (this.tableContainer.innerHTML = ""), this.axis.innerHTML = "", this.tracks.innerHTML = "", this.legendContainer.innerHTML = "", this.showTable && this.renderTable(), this.renderAxis(), this.grid = this.el("div", "grid-lines-container", this.tracks), this.indicators = this.el("div", "indicators-container", this.tracks), this.renderGrid(), this.renderCurrentDateLine(), this.rows = this.data.map((e) => this.createRow(e)), this.toggleContainer = this.el("div", "timeline-more-toggle", this.tracks, {
94
161
  paddingTop: "10px",
95
162
  display: "flex",
96
163
  justifyContent: "center",
@@ -109,17 +176,17 @@ class v {
109
176
  * @param {MouseEvent} e - The mouse event.
110
177
  * @param {string} content - The HTML content for the tooltip.
111
178
  */
112
- showTooltip(t, e) {
113
- this.tooltip.innerHTML = e, this.tooltip.style.display = "block", this.updateTooltipPos(t);
179
+ showTooltip(e, i) {
180
+ this.tooltip.innerHTML = i, this.tooltip.style.display = "block", this.updateTooltipPos(e);
114
181
  }
115
182
  /**
116
183
  * Updates tooltip position relative to mouse.
117
184
  * @param {MouseEvent} e - The mouse event.
118
185
  */
119
- updateTooltipPos(t) {
120
- let i = t.pageX + 12, s = t.pageY + 12;
121
- const r = this.tooltip.offsetWidth, o = this.tooltip.offsetHeight;
122
- i + r > window.innerWidth && (i = t.pageX - r - 12), s + o > window.innerHeight && (s = t.pageY - o - 12), this.tooltip.style.left = `${i}px`, this.tooltip.style.top = `${s}px`;
186
+ updateTooltipPos(e) {
187
+ let s = e.pageX + 12, t = e.pageY + 12;
188
+ const n = this.tooltip.offsetWidth, o = this.tooltip.offsetHeight;
189
+ s + n > window.innerWidth && (s = e.pageX - n - 12), t + o > window.innerHeight && (t = e.pageY - o - 12), this.tooltip.style.left = `${s}px`, this.tooltip.style.top = `${t}px`;
123
190
  }
124
191
  /**
125
192
  * Hides the tooltip.
@@ -127,6 +194,20 @@ class v {
127
194
  hideTooltip() {
128
195
  this.tooltip && (this.tooltip.style.display = "none");
129
196
  }
197
+ /**
198
+ * Highlights the search term in a given text.
199
+ * @param {string} text - The text to process.
200
+ * @returns {string} The HTML with highlighted segments.
201
+ */
202
+ highlight(e) {
203
+ if (!this.filterText) return e;
204
+ try {
205
+ const i = new RegExp(`(${this.filterText})`, "gi");
206
+ return e.replace(i, '<mark class="highlight-match">$1</mark>');
207
+ } catch {
208
+ return e;
209
+ }
210
+ }
130
211
  /**
131
212
  * Helper to create DOM elements with styles and classes.
132
213
  * @param {string} tag - HTML tag.
@@ -135,33 +216,33 @@ class v {
135
216
  * @param {Object} [styles={}] - Inline styles.
136
217
  * @returns {HTMLElement} The created element.
137
218
  */
138
- el(t, e, i, s = {}) {
139
- const r = document.createElement(t);
140
- return e && (r.className = e), Object.assign(r.style, s), i && i.appendChild(r), r;
219
+ el(e, i, s, t = {}) {
220
+ const n = document.createElement(e);
221
+ return i && (n.className = i), Object.assign(n.style, t), s && s.appendChild(n), n;
141
222
  }
142
223
  /**
143
224
  * Renders the toolbar with the filter input.
144
225
  */
145
226
  renderToolbar() {
146
- const t = this.el("div", "timeline-toolbar", this.root), e = this.el("div", "timeline-filter-container", t);
147
- e.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>';
148
- const i = this.el("input", "timeline-filter-input", e);
149
- i.placeholder = this.t("filter"), i.value = this.filterText, i.setAttribute("aria-label", this.t("filter")), i.oninput = (s) => {
150
- this.filterText = s.target.value.toLowerCase().trim(), this.updateVisibility();
227
+ const e = this.el("div", "timeline-toolbar", this.root), i = this.el("div", "timeline-filter-container", e);
228
+ i.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>';
229
+ const s = this.el("input", "timeline-filter-input", i);
230
+ s.placeholder = this.t("filter"), s.value = this.filterText, s.setAttribute("aria-label", this.t("filter")), s.oninput = (t) => {
231
+ this.filterText = t.target.value.toLowerCase().trim(), this.updateVisibility();
151
232
  };
152
233
  }
153
234
  /**
154
235
  * Renders the theme toggle button.
155
236
  */
156
237
  renderThemeToggle() {
157
- const t = this.el("button", "theme-toggle-btn", this.root);
158
- t.title = this.t("dark"), t.setAttribute("aria-label", this.t("dark"));
159
- const e = {
238
+ const e = this.el("button", "theme-toggle-btn", this.root);
239
+ e.title = this.t("dark"), e.setAttribute("aria-label", this.t("dark"));
240
+ const i = {
160
241
  moon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>',
161
242
  sun: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>'
162
243
  };
163
- t.innerHTML = this.theme === "dark" ? e.sun : e.moon, t.onclick = () => {
164
- this.theme = this.theme === "light" ? "dark" : "light", document.documentElement.setAttribute("data-theme", this.theme), t.innerHTML = this.theme === "dark" ? e.sun : e.moon, t.setAttribute("aria-pressed", this.theme === "dark");
244
+ e.innerHTML = this.theme === "dark" ? i.sun : i.moon, e.onclick = () => {
245
+ this.theme = this.theme === "light" ? "dark" : "light", document.documentElement.setAttribute("data-theme", this.theme), e.innerHTML = this.theme === "dark" ? i.sun : i.moon, e.setAttribute("aria-pressed", this.theme === "dark");
165
246
  };
166
247
  }
167
248
  /**
@@ -170,10 +251,10 @@ class v {
170
251
  renderLegend() {
171
252
  this.legendContainer.innerHTML = `
172
253
  <div class="release-legend" role="complementary" aria-label="Support Legend">
173
- ${["oss", "ent", "eol"].map((t) => `
174
- <div class="legend-block ${t === "ent" ? "commercial" : t}">
254
+ ${["oss", "ent", "eol"].map((e) => `
255
+ <div class="legend-block ${e === "ent" ? "commercial" : e}">
175
256
  <div class="legend-icon" aria-hidden="true"></div>
176
- <div><h3>${this.t(t)}</h3><p>${this.t(t + "Desc")}</p></div>
257
+ <div><h3>${this.t(e)}</h3><p>${this.t(e + "Desc")}</p></div>
177
258
  </div>
178
259
  `).join("")}
179
260
  </div>
@@ -184,19 +265,19 @@ class v {
184
265
  */
185
266
  renderAxis() {
186
267
  this.el("div", "", this.axis, { width: "96px", flexShrink: "0" }).setAttribute("role", "presentation");
187
- for (let e = this.minYear; e <= this.maxYear; e++) {
188
- const i = this.el("div", "timeline-year", this.axis);
189
- i.textContent = e, i.setAttribute("role", "columnheader");
268
+ for (let i = this.minYear; i <= this.maxYear; i++) {
269
+ const s = this.el("div", "timeline-year", this.axis);
270
+ s.textContent = i, s.setAttribute("role", "columnheader");
190
271
  }
191
272
  }
192
273
  /**
193
274
  * Renders the vertical grid lines for years.
194
275
  */
195
276
  renderGrid() {
196
- const t = new Date(this.minYear, 0, 1).getTime(), e = new Date(this.maxYear, 11, 31).getTime() - t;
197
- for (let i = this.minYear; i <= this.maxYear; i++) {
198
- const s = this.el("div", "year-grid-line", this.grid);
199
- s.style.left = `${(new Date(i, 0, 1).getTime() - t) / e * 100}%`, s.setAttribute("role", "presentation");
277
+ const e = new Date(this.minYear, 0, 1).getTime(), i = new Date(this.maxYear, 11, 31).getTime() - e;
278
+ for (let s = this.minYear; s <= this.maxYear; s++) {
279
+ const t = this.el("div", "year-grid-line", this.grid);
280
+ t.style.left = `${(new Date(s, 0, 1).getTime() - e) / i * 100}%`, t.setAttribute("role", "presentation");
200
281
  }
201
282
  }
202
283
  /**
@@ -204,18 +285,19 @@ class v {
204
285
  * @param {Object} item - The version data item.
205
286
  * @returns {Object} Metadata about the created row.
206
287
  */
207
- createRow(t) {
208
- const e = this.el("div", "timeline-row", this.tracks);
209
- e.setAttribute("role", "row");
210
- const i = this.el("div", "version-label", e);
211
- i.setAttribute("role", "rowheader");
212
- const s = Date.now(), r = new Date(t.ossStart).getTime(), o = new Date(t.ossEnd).getTime(), h = t.enterpriseEnd ? new Date(t.enterpriseEnd).getTime() : o, l = s >= r && s <= o ? "status-oss" : s > o && s <= h ? "status-ent" : s > h ? "status-expired" : "";
213
- if (l && i.classList.add(l), t.releaseNotesUrl) {
214
- const n = this.el("a", "version-link", i);
215
- n.href = t.releaseNotesUrl, n.target = "_blank", n.textContent = t.version, n.title = this.t("notes", { v: t.version }), n.setAttribute("aria-label", this.t("notes", { v: t.version }));
216
- } else i.textContent = t.version;
217
- const a = this.el("div", "track-container", e);
218
- return a.setAttribute("role", "gridcell"), a.appendChild(this.createBar(t, t.ossStart, t.enterpriseEnd || t.ossEnd, "bar-ent", this.t("ent"))), a.appendChild(this.createBar(t, t.ossStart, t.ossEnd, "bar-oss", this.t("oss"))), { el: e, version: t.version.toLowerCase() };
288
+ createRow(e) {
289
+ const i = this.el("div", "timeline-row", this.tracks);
290
+ i.setAttribute("role", "row");
291
+ const s = this.el("div", "version-label", i);
292
+ s.setAttribute("role", "rowheader");
293
+ const t = Date.now(), n = new Date(e.ossStart).getTime(), o = new Date(e.ossEnd).getTime(), a = e.enterpriseEnd ? new Date(e.enterpriseEnd).getTime() : o, l = t >= n && t <= o ? "status-oss" : t > o && t <= a ? "status-ent" : t > a ? "status-expired" : "";
294
+ if (l && s.classList.add(l), e.releaseNotesUrl) {
295
+ const r = this.el("a", "version-link", s);
296
+ r.href = e.releaseNotesUrl, r.target = "_blank", r.innerHTML = this.highlight(e.version), r.title = this.t("notes", { v: e.version }), r.setAttribute("aria-label", this.t("notes", { v: e.version }));
297
+ } else
298
+ s.innerHTML = this.highlight(e.version);
299
+ const h = this.el("div", "track-container", i);
300
+ return h.setAttribute("role", "gridcell"), h.appendChild(this.createBar(e, e.ossStart, e.enterpriseEnd || e.ossEnd, "bar-ent", this.t("ent"))), h.appendChild(this.createBar(e, e.ossStart, e.ossEnd, "bar-oss", this.t("oss"))), { el: i, version: e.version.toLowerCase(), versionOriginal: e.version };
219
301
  }
220
302
  /**
221
303
  * Creates a colored bar segment for the timeline.
@@ -226,29 +308,34 @@ class v {
226
308
  * @param {string} prefix - Support type prefix for tooltip.
227
309
  * @returns {HTMLElement} The created bar element.
228
310
  */
229
- createBar(t, e, i, s, r) {
230
- const o = new Date(e).getTime(), h = new Date(i).getTime(), l = new Date(this.minYear, 0, 1).getTime(), a = new Date(this.maxYear, 11, 31).getTime() - l, n = this.el("div", `bar-segment ${s}`);
231
- n.style.left = `${(o - l) / a * 100}%`, n.style.width = `${(h - o) / a * 100}%`, n.setAttribute("role", "img"), n.setAttribute("aria-label", `${t.version} ${r}: ${e} to ${i}`), n.tabIndex = 0;
311
+ createBar(e, i, s, t, n) {
312
+ const o = new Date(i).getTime(), a = new Date(s).getTime(), l = new Date(this.minYear, 0, 1).getTime(), h = new Date(this.maxYear, 11, 31).getTime() - l, r = this.el("div", `bar-segment ${t}`);
313
+ r.style.left = `${(o - l) / h * 100}%`, r.style.width = `${(a - o) / h * 100}%`, r.setAttribute("role", "img"), r.setAttribute("aria-label", `${e.version} ${n}: ${i} to ${s}`), r.tabIndex = 0;
232
314
  const c = `
233
- <div class="tooltip-header">${r} - ${t.version}</div>
234
- <div class="tooltip-date"><strong>Du:</strong> ${e}</div>
235
- <div class="tooltip-date"><strong>Au:</strong> ${i}</div>
315
+ <div class="tooltip-header">${n} - ${e.version}</div>
316
+ <div class="tooltip-date"><strong>Du:</strong> ${i}</div>
317
+ <div class="tooltip-date"><strong>Au:</strong> ${s}</div>
236
318
  `;
237
- return n.onmouseenter = (d) => this.showTooltip(d, c), n.onmousemove = (d) => this.updateTooltipPos(d), n.onmouseleave = () => this.hideTooltip(), n.onfocus = (d) => {
238
- const p = n.getBoundingClientRect();
319
+ return r.onmouseenter = (d) => this.showTooltip(d, c), r.onmousemove = (d) => this.updateTooltipPos(d), r.onmouseleave = () => this.hideTooltip(), r.onfocus = (d) => {
320
+ const p = r.getBoundingClientRect();
239
321
  this.showTooltip({ pageX: p.left + window.scrollX, pageY: p.top + window.scrollY - 40 }, c);
240
- }, n.onblur = () => this.hideTooltip(), n;
322
+ }, r.onblur = () => this.hideTooltip(), r;
241
323
  }
242
324
  /**
243
325
  * Updates visibility of rows based on filtering and expansion state.
244
326
  */
245
327
  updateVisibility() {
246
- const t = this.rows.filter((s) => s.version.includes(this.filterText));
247
- this.rows.forEach((s) => s.el.style.display = "none");
248
- const e = this.filterText === "" && t.length > this.visibleCount;
249
- if ((e && !this.isExpanded ? t.slice(0, this.visibleCount) : t).forEach((s) => s.el.style.display = "flex"), this.toggleContainer.innerHTML = "", e) {
250
- const s = this.el("button", "timeline-toggle-btn", this.toggleContainer), r = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="${this.isExpanded ? "18 15 12 9 6 15" : "6 9 12 15 18 9"}"></polyline></svg>`;
251
- s.innerHTML = this.isExpanded ? `${this.t("less")} ${r}` : `${this.t("more", { n: t.length - this.visibleCount })} ${r}`, s.setAttribute("aria-expanded", this.isExpanded), s.onclick = () => {
328
+ const e = this.rows.filter((t) => t.version.includes(this.filterText));
329
+ this.rows.forEach((t) => {
330
+ const n = t.el.querySelector(".version-label"), o = n.querySelector(".version-link");
331
+ o ? o.innerHTML = this.highlight(t.versionOriginal || t.version) : n.innerHTML = this.highlight(t.versionOriginal || t.version), t.el.classList.remove("row-visible"), t.el.classList.add("row-hidden");
332
+ });
333
+ const i = this.filterText === "" && e.length > this.visibleCount;
334
+ if ((i && !this.isExpanded ? e.slice(0, this.visibleCount) : e).forEach((t) => {
335
+ t.el.classList.remove("row-hidden"), t.el.classList.add("row-visible");
336
+ }), this.showTable && this.updateTableVisibility(), this.toggleContainer.innerHTML = "", i) {
337
+ const t = this.el("button", "timeline-toggle-btn", this.toggleContainer), n = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="${this.isExpanded ? "18 15 12 9 6 15" : "6 9 12 15 18 9"}"></polyline></svg>`;
338
+ t.innerHTML = this.isExpanded ? `${this.t("less")} ${n}` : `${this.t("more", { n: e.length - this.visibleCount })} ${n}`, t.setAttribute("aria-expanded", this.isExpanded), t.onclick = () => {
252
339
  this.isExpanded = !this.isExpanded, this.updateVisibility();
253
340
  };
254
341
  }
@@ -257,14 +344,14 @@ class v {
257
344
  * Renders the vertical line indicating current date.
258
345
  */
259
346
  renderCurrentDateLine() {
260
- const t = new Date(this.minYear, 0, 1).getTime(), e = new Date(this.maxYear, 11, 31).getTime() - t, i = Date.now() - t;
261
- if (i < 0 || i > e) return;
262
- const s = (/* @__PURE__ */ new Date()).toISOString().split("T")[0], r = this.el("div", "current-date-indicator", this.indicators);
263
- r.style.left = `${i / e * 100}%`, r.setAttribute("role", "separator"), r.setAttribute("aria-label", this.t("today", { date: s }));
264
- const o = this.el("div", "current-date-badge", r);
265
- o.textContent = s, o.setAttribute("aria-hidden", "true");
347
+ const e = new Date(this.minYear, 0, 1).getTime(), i = new Date(this.maxYear, 11, 31).getTime() - e, s = Date.now() - e;
348
+ if (s < 0 || s > i) return;
349
+ const t = (/* @__PURE__ */ new Date()).toISOString().split("T")[0], n = this.el("div", "current-date-indicator", this.indicators);
350
+ n.style.left = `${s / i * 100}%`, n.setAttribute("role", "separator"), n.setAttribute("aria-label", this.t("today", { date: t }));
351
+ const o = this.el("div", "current-date-badge", n);
352
+ o.textContent = t, o.setAttribute("aria-hidden", "true");
266
353
  }
267
354
  }
268
355
  export {
269
- v as default
356
+ b as default
270
357
  };
@@ -1,14 +1,14 @@
1
- (function(a,h){typeof exports=="object"&&typeof module<"u"?module.exports=h():typeof define=="function"&&define.amd?define(h):(a=typeof globalThis<"u"?globalThis:a||self,a.Timeline=h())})(this,(function(){"use strict";const a={en:{filter:"Filter versions...",oss:"OSS Support",ent:"Enterprise Support",eol:"Out of Support",less:"Show Less",more:"Show {n} more versions",notes:"View Release Notes for {v}",dark:"Toggle Dark Mode",ossDesc:"Free security updates and bugfixes.",entDesc:"Expert support during OSS plus extended support after EOL.",eolDesc:"End of life. No further updates are provided.",today:"Today's date: {date}"},fr:{filter:"Filtrer les versions...",oss:"Support OSS",ent:"Support Entreprise",eol:"Fin de support",less:"Voir moins",more:"Voir {n} versions supplémentaires",notes:"Voir les notes de version pour {v}",dark:"Changer le mode sombre",ossDesc:"Mises à jour de sécurité et corrections de bugs gratuites.",entDesc:"Support pendant la période OSS plus support étendu après.",eolDesc:"Version en fin de vie. Plus de mises à jour.",today:"Date du jour : {date}"}};class h{constructor(t,e,i={}){this.root=document.getElementById(t),this.root&&(this.options=i,this.visibleCount=i.visibleCount||3,this.isExpanded=!1,this.theme="light",this.filterText="",this.translations={...a,...i.i18n||{}},this.locale=i.locale||this.detectLanguage(),this.setupBaseLayout(),this.updateData(e))}detectLanguage(){const t=(navigator.language||"en").split("-")[0];return this.translations[t]?t:"en"}t(t,e={}){let i=(this.translations[this.locale]||this.translations.en)[t]||t;return Object.keys(e).forEach(s=>i=i.replace(`{${s}}`,e[s])),i}setupBaseLayout(){this.root.innerHTML="",this.root.setAttribute("role","application"),this.root.setAttribute("aria-label","Product Lifecycle Timeline"),this.renderToolbar(),this.renderThemeToggle(),this.wrapper=this.el("div","timeline-wrapper",this.root),this.wrapper.setAttribute("role","grid"),this.wrapper.setAttribute("aria-readonly","true"),this.axis=this.el("div","timeline-axis",this.wrapper),this.axis.setAttribute("role","row"),this.tracks=this.el("div","timeline-tracks",this.wrapper),this.legendContainer=this.el("div","timeline-legend-container",this.wrapper)}updateData(t){this.data=(t||[]).filter(e=>e),this.calculateTimeRange(),this.render()}calculateTimeRange(){if(this.currentDate=new Date,!this.data.length){this.minYear=this.currentDate.getFullYear()-1,this.maxYear=this.currentDate.getFullYear()+3;return}const t=this.data.flatMap(e=>[new Date(e.ossStart),new Date(e.enterpriseEnd||e.ossEnd)]);this.minYear=new Date(Math.min(...t)).getFullYear(),this.maxYear=new Date(Math.max(...t)).getFullYear()}render(){this.axis.innerHTML="",this.tracks.innerHTML="",this.legendContainer.innerHTML="",this.renderAxis(),this.grid=this.el("div","grid-lines-container",this.tracks),this.indicators=this.el("div","indicators-container",this.tracks),this.renderGrid(),this.renderCurrentDateLine(),this.rows=this.data.map(t=>this.createRow(t)),this.toggleContainer=this.el("div","timeline-more-toggle",this.tracks,{paddingTop:"10px",display:"flex",justifyContent:"center",position:"relative",zIndex:"10"}),this.updateVisibility(),this.renderLegend(),this.setupTooltip()}setupTooltip(){this.tooltip&&this.tooltip.remove(),this.tooltip=this.el("div","timeline-tooltip-overlay",document.body),this.tooltip.style.display="none",this.tooltip.setAttribute("role","tooltip")}showTooltip(t,e){this.tooltip.innerHTML=e,this.tooltip.style.display="block",this.updateTooltipPos(t)}updateTooltipPos(t){let i=t.pageX+12,s=t.pageY+12;const n=this.tooltip.offsetWidth,o=this.tooltip.offsetHeight;i+n>window.innerWidth&&(i=t.pageX-n-12),s+o>window.innerHeight&&(s=t.pageY-o-12),this.tooltip.style.left=`${i}px`,this.tooltip.style.top=`${s}px`}hideTooltip(){this.tooltip&&(this.tooltip.style.display="none")}el(t,e,i,s={}){const n=document.createElement(t);return e&&(n.className=e),Object.assign(n.style,s),i&&i.appendChild(n),n}renderToolbar(){const t=this.el("div","timeline-toolbar",this.root),e=this.el("div","timeline-filter-container",t);e.innerHTML='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>';const i=this.el("input","timeline-filter-input",e);i.placeholder=this.t("filter"),i.value=this.filterText,i.setAttribute("aria-label",this.t("filter")),i.oninput=s=>{this.filterText=s.target.value.toLowerCase().trim(),this.updateVisibility()}}renderThemeToggle(){const t=this.el("button","theme-toggle-btn",this.root);t.title=this.t("dark"),t.setAttribute("aria-label",this.t("dark"));const e={moon:'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>',sun:'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>'};t.innerHTML=this.theme==="dark"?e.sun:e.moon,t.onclick=()=>{this.theme=this.theme==="light"?"dark":"light",document.documentElement.setAttribute("data-theme",this.theme),t.innerHTML=this.theme==="dark"?e.sun:e.moon,t.setAttribute("aria-pressed",this.theme==="dark")}}renderLegend(){this.legendContainer.innerHTML=`
1
+ (function(c,p){typeof exports=="object"&&typeof module<"u"?module.exports=p():typeof define=="function"&&define.amd?define(p):(c=typeof globalThis<"u"?globalThis:c||self,c.Timeline=p())})(this,(function(){"use strict";const c={en:{filter:"Filter versions...",oss:"OSS Support",ent:"Enterprise Support",eol:"Out of Support",less:"Show Less",more:"Show {n} more versions",notes:"View Release Notes for {v}",dark:"Toggle Dark Mode",ossDesc:"Free security updates and bugfixes.",entDesc:"Expert support during OSS plus extended support after EOL.",eolDesc:"End of life. No further updates are provided.",today:"Today's date: {date}",branch:"Branch",initial:"Initial Release",ossEnd:"End OSS",entEnd:"End Enterprise *"},fr:{filter:"Filtrer les versions...",oss:"Support OSS",ent:"Support Entreprise",eol:"Fin de support",less:"Voir moins",more:"Voir {n} versions supplémentaires",notes:"Voir les notes de version pour {v}",dark:"Changer le mode sombre",ossDesc:"Mises à jour de sécurité et corrections de bugs gratuites.",entDesc:"Support pendant la période OSS plus support étendu après.",eolDesc:"Version en fin de vie. Plus de mises à jour.",today:"Date du jour : {date}",branch:"Version",initial:"Sortie initiale",ossEnd:"Fin OSS",entEnd:"Fin Entreprise *"}};class p{constructor(e,i,s={}){this.root=document.getElementById(e),this.root&&(this.options=s,this.visibleCount=s.visibleCount||3,this.showTable=s.showTable!==!1,this.isExpanded=!1,this.isTableExpanded=!1,this.theme="light",this.filterText="",this.translations={...c,...s.i18n||{}},this.locale=s.locale||this.detectLanguage(),this.setupBaseLayout(),this.updateData(i))}detectLanguage(){const e=(navigator.language||"en").split("-")[0];return this.translations[e]?e:"en"}t(e,i={}){let s=(this.translations[this.locale]||this.translations.en)[e]||e;return Object.keys(i).forEach(t=>s=s.replace(`{${t}}`,i[t])),s}setupBaseLayout(){this.root.innerHTML="",this.root.setAttribute("role","application"),this.root.setAttribute("aria-label","Product Lifecycle Timeline"),this.renderToolbar(),this.renderThemeToggle(),this.showTable&&(this.tableContainer=this.el("div","timeline-table-container",this.root)),this.wrapper=this.el("div","timeline-wrapper",this.root),this.wrapper.setAttribute("role","grid"),this.wrapper.setAttribute("aria-readonly","true"),this.axis=this.el("div","timeline-axis",this.wrapper),this.axis.setAttribute("role","row"),this.tracks=this.el("div","timeline-tracks",this.wrapper),this.legendContainer=this.el("div","timeline-legend-container",this.wrapper)}updateData(e){this.data=this.validateData(e||[]),this.calculateTimeRange(),this.render()}validateData(e){return e.filter((i,s)=>{const n=["version","ossStart","ossEnd"].filter(l=>!i[l]);if(n.length>0)return console.warn(`[Timeline] Missing fields for item at index ${s}: ${n.join(", ")}`),!1;const a=["ossStart","ossEnd","enterpriseEnd"].filter(l=>i[l]).filter(l=>isNaN(new Date(i[l]).getTime()));return a.length>0?(console.warn(`[Timeline] Invalid date format for item "${i.version}": ${a.join(", ")}`),!1):!0})}calculateTimeRange(){if(this.currentDate=new Date,!this.data.length){this.minYear=this.currentDate.getFullYear()-1,this.maxYear=this.currentDate.getFullYear()+3;return}const e=this.data.flatMap(i=>[new Date(i.ossStart),new Date(i.enterpriseEnd||i.ossEnd)]);this.minYear=new Date(Math.min(...e)).getFullYear(),this.maxYear=new Date(Math.max(...e)).getFullYear()}renderTable(){const e=this.el("table","timeline-table",this.tableContainer);e.setAttribute("aria-label","Project support");const i=this.el("thead","",e),s=this.el("tr","",i);[this.t("branch"),this.t("initial"),this.t("ossEnd"),this.t("entEnd")].forEach(t=>{this.el("th","",s).textContent=t}),this.tableBody=this.el("tbody","",e),this.tableRows=this.data.map(t=>this.createTableRow(t)),this.tableToggleContainer=this.el("div","timeline-table-toggle",this.tableContainer),this.updateTableVisibility()}createTableRow(e){const i=this.el("tr","",this.tableBody),s=Date.now(),t=new Date(e.ossStart).getTime(),n=new Date(e.ossEnd).getTime(),o=e.enterpriseEnd?new Date(e.enterpriseEnd).getTime():n,a=s>=t&&s<=n?"status-oss":s>n&&s<=o?"status-ent":s>o?"status-expired":"",l=this.el("td","",i);let h=`<span class="table-badge ${a}">${e.versionOriginal||e.version}</span>`;e.releaseNotesUrl?l.innerHTML=`<a href="${e.releaseNotesUrl}" target="_blank" class="table-version-link">${h}</a>`:l.innerHTML=h;const r=this.el("td","",i);r.textContent=e.ossStart,s>t&&(r.className="past-date");const u=this.el("td","",i);u.textContent=e.ossEnd,s>n&&(u.className="past-date");const d=this.el("td","",i);return d.textContent=e.enterpriseEnd||e.ossEnd,s>o&&(d.className="past-date"),{el:i,version:e.version.toLowerCase(),versionOriginal:e.versionOriginal||e.version}}updateTableVisibility(){const e=this.tableRows.filter(t=>t.version.includes(this.filterText));this.tableRows.forEach(t=>{const n=t.el.querySelector(".table-badge");n.innerHTML=this.highlight(t.versionOriginal||t.version),t.el.classList.remove("row-visible"),t.el.classList.add("row-hidden")});const i=this.filterText===""&&e.length>this.visibleCount;if((i&&!this.isTableExpanded?e.slice(0,this.visibleCount):e).forEach(t=>{t.el.classList.remove("row-hidden"),t.el.classList.add("row-visible")}),this.tableToggleContainer.innerHTML="",i){const t=this.el("button","timeline-toggle-btn table-toggle",this.tableToggleContainer),n=`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="${this.isTableExpanded?"18 15 12 9 6 15":"6 9 12 15 18 9"}"></polyline></svg>`;t.innerHTML=this.isTableExpanded?`${this.t("less")} ${n}`:`${this.t("more",{n:e.length-this.visibleCount})} ${n}`,t.onclick=()=>{this.isTableExpanded=!this.isTableExpanded,this.updateTableVisibility()}}}render(){this.showTable&&(this.tableContainer.innerHTML=""),this.axis.innerHTML="",this.tracks.innerHTML="",this.legendContainer.innerHTML="",this.showTable&&this.renderTable(),this.renderAxis(),this.grid=this.el("div","grid-lines-container",this.tracks),this.indicators=this.el("div","indicators-container",this.tracks),this.renderGrid(),this.renderCurrentDateLine(),this.rows=this.data.map(e=>this.createRow(e)),this.toggleContainer=this.el("div","timeline-more-toggle",this.tracks,{paddingTop:"10px",display:"flex",justifyContent:"center",position:"relative",zIndex:"10"}),this.updateVisibility(),this.renderLegend(),this.setupTooltip()}setupTooltip(){this.tooltip&&this.tooltip.remove(),this.tooltip=this.el("div","timeline-tooltip-overlay",document.body),this.tooltip.style.display="none",this.tooltip.setAttribute("role","tooltip")}showTooltip(e,i){this.tooltip.innerHTML=i,this.tooltip.style.display="block",this.updateTooltipPos(e)}updateTooltipPos(e){let s=e.pageX+12,t=e.pageY+12;const n=this.tooltip.offsetWidth,o=this.tooltip.offsetHeight;s+n>window.innerWidth&&(s=e.pageX-n-12),t+o>window.innerHeight&&(t=e.pageY-o-12),this.tooltip.style.left=`${s}px`,this.tooltip.style.top=`${t}px`}hideTooltip(){this.tooltip&&(this.tooltip.style.display="none")}highlight(e){if(!this.filterText)return e;try{const i=new RegExp(`(${this.filterText})`,"gi");return e.replace(i,'<mark class="highlight-match">$1</mark>')}catch{return e}}el(e,i,s,t={}){const n=document.createElement(e);return i&&(n.className=i),Object.assign(n.style,t),s&&s.appendChild(n),n}renderToolbar(){const e=this.el("div","timeline-toolbar",this.root),i=this.el("div","timeline-filter-container",e);i.innerHTML='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>';const s=this.el("input","timeline-filter-input",i);s.placeholder=this.t("filter"),s.value=this.filterText,s.setAttribute("aria-label",this.t("filter")),s.oninput=t=>{this.filterText=t.target.value.toLowerCase().trim(),this.updateVisibility()}}renderThemeToggle(){const e=this.el("button","theme-toggle-btn",this.root);e.title=this.t("dark"),e.setAttribute("aria-label",this.t("dark"));const i={moon:'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>',sun:'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>'};e.innerHTML=this.theme==="dark"?i.sun:i.moon,e.onclick=()=>{this.theme=this.theme==="light"?"dark":"light",document.documentElement.setAttribute("data-theme",this.theme),e.innerHTML=this.theme==="dark"?i.sun:i.moon,e.setAttribute("aria-pressed",this.theme==="dark")}}renderLegend(){this.legendContainer.innerHTML=`
2
2
  <div class="release-legend" role="complementary" aria-label="Support Legend">
3
- ${["oss","ent","eol"].map(t=>`
4
- <div class="legend-block ${t==="ent"?"commercial":t}">
3
+ ${["oss","ent","eol"].map(e=>`
4
+ <div class="legend-block ${e==="ent"?"commercial":e}">
5
5
  <div class="legend-icon" aria-hidden="true"></div>
6
- <div><h3>${this.t(t)}</h3><p>${this.t(t+"Desc")}</p></div>
6
+ <div><h3>${this.t(e)}</h3><p>${this.t(e+"Desc")}</p></div>
7
7
  </div>
8
8
  `).join("")}
9
9
  </div>
10
- `}renderAxis(){this.el("div","",this.axis,{width:"96px",flexShrink:"0"}).setAttribute("role","presentation");for(let e=this.minYear;e<=this.maxYear;e++){const i=this.el("div","timeline-year",this.axis);i.textContent=e,i.setAttribute("role","columnheader")}}renderGrid(){const t=new Date(this.minYear,0,1).getTime(),e=new Date(this.maxYear,11,31).getTime()-t;for(let i=this.minYear;i<=this.maxYear;i++){const s=this.el("div","year-grid-line",this.grid);s.style.left=`${(new Date(i,0,1).getTime()-t)/e*100}%`,s.setAttribute("role","presentation")}}createRow(t){const e=this.el("div","timeline-row",this.tracks);e.setAttribute("role","row");const i=this.el("div","version-label",e);i.setAttribute("role","rowheader");const s=Date.now(),n=new Date(t.ossStart).getTime(),o=new Date(t.ossEnd).getTime(),p=t.enterpriseEnd?new Date(t.enterpriseEnd).getTime():o,d=s>=n&&s<=o?"status-oss":s>o&&s<=p?"status-ent":s>p?"status-expired":"";if(d&&i.classList.add(d),t.releaseNotesUrl){const r=this.el("a","version-link",i);r.href=t.releaseNotesUrl,r.target="_blank",r.textContent=t.version,r.title=this.t("notes",{v:t.version}),r.setAttribute("aria-label",this.t("notes",{v:t.version}))}else i.textContent=t.version;const l=this.el("div","track-container",e);return l.setAttribute("role","gridcell"),l.appendChild(this.createBar(t,t.ossStart,t.enterpriseEnd||t.ossEnd,"bar-ent",this.t("ent"))),l.appendChild(this.createBar(t,t.ossStart,t.ossEnd,"bar-oss",this.t("oss"))),{el:e,version:t.version.toLowerCase()}}createBar(t,e,i,s,n){const o=new Date(e).getTime(),p=new Date(i).getTime(),d=new Date(this.minYear,0,1).getTime(),l=new Date(this.maxYear,11,31).getTime()-d,r=this.el("div",`bar-segment ${s}`);r.style.left=`${(o-d)/l*100}%`,r.style.width=`${(p-o)/l*100}%`,r.setAttribute("role","img"),r.setAttribute("aria-label",`${t.version} ${n}: ${e} to ${i}`),r.tabIndex=0;const u=`
11
- <div class="tooltip-header">${n} - ${t.version}</div>
12
- <div class="tooltip-date"><strong>Du:</strong> ${e}</div>
13
- <div class="tooltip-date"><strong>Au:</strong> ${i}</div>
14
- `;return r.onmouseenter=c=>this.showTooltip(c,u),r.onmousemove=c=>this.updateTooltipPos(c),r.onmouseleave=()=>this.hideTooltip(),r.onfocus=c=>{const g=r.getBoundingClientRect();this.showTooltip({pageX:g.left+window.scrollX,pageY:g.top+window.scrollY-40},u)},r.onblur=()=>this.hideTooltip(),r}updateVisibility(){const t=this.rows.filter(s=>s.version.includes(this.filterText));this.rows.forEach(s=>s.el.style.display="none");const e=this.filterText===""&&t.length>this.visibleCount;if((e&&!this.isExpanded?t.slice(0,this.visibleCount):t).forEach(s=>s.el.style.display="flex"),this.toggleContainer.innerHTML="",e){const s=this.el("button","timeline-toggle-btn",this.toggleContainer),n=`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="${this.isExpanded?"18 15 12 9 6 15":"6 9 12 15 18 9"}"></polyline></svg>`;s.innerHTML=this.isExpanded?`${this.t("less")} ${n}`:`${this.t("more",{n:t.length-this.visibleCount})} ${n}`,s.setAttribute("aria-expanded",this.isExpanded),s.onclick=()=>{this.isExpanded=!this.isExpanded,this.updateVisibility()}}}renderCurrentDateLine(){const t=new Date(this.minYear,0,1).getTime(),e=new Date(this.maxYear,11,31).getTime()-t,i=Date.now()-t;if(i<0||i>e)return;const s=new Date().toISOString().split("T")[0],n=this.el("div","current-date-indicator",this.indicators);n.style.left=`${i/e*100}%`,n.setAttribute("role","separator"),n.setAttribute("aria-label",this.t("today",{date:s}));const o=this.el("div","current-date-badge",n);o.textContent=s,o.setAttribute("aria-hidden","true")}}return h}));
10
+ `}renderAxis(){this.el("div","",this.axis,{width:"96px",flexShrink:"0"}).setAttribute("role","presentation");for(let i=this.minYear;i<=this.maxYear;i++){const s=this.el("div","timeline-year",this.axis);s.textContent=i,s.setAttribute("role","columnheader")}}renderGrid(){const e=new Date(this.minYear,0,1).getTime(),i=new Date(this.maxYear,11,31).getTime()-e;for(let s=this.minYear;s<=this.maxYear;s++){const t=this.el("div","year-grid-line",this.grid);t.style.left=`${(new Date(s,0,1).getTime()-e)/i*100}%`,t.setAttribute("role","presentation")}}createRow(e){const i=this.el("div","timeline-row",this.tracks);i.setAttribute("role","row");const s=this.el("div","version-label",i);s.setAttribute("role","rowheader");const t=Date.now(),n=new Date(e.ossStart).getTime(),o=new Date(e.ossEnd).getTime(),a=e.enterpriseEnd?new Date(e.enterpriseEnd).getTime():o,l=t>=n&&t<=o?"status-oss":t>o&&t<=a?"status-ent":t>a?"status-expired":"";if(l&&s.classList.add(l),e.releaseNotesUrl){const r=this.el("a","version-link",s);r.href=e.releaseNotesUrl,r.target="_blank",r.innerHTML=this.highlight(e.version),r.title=this.t("notes",{v:e.version}),r.setAttribute("aria-label",this.t("notes",{v:e.version}))}else s.innerHTML=this.highlight(e.version);const h=this.el("div","track-container",i);return h.setAttribute("role","gridcell"),h.appendChild(this.createBar(e,e.ossStart,e.enterpriseEnd||e.ossEnd,"bar-ent",this.t("ent"))),h.appendChild(this.createBar(e,e.ossStart,e.ossEnd,"bar-oss",this.t("oss"))),{el:i,version:e.version.toLowerCase(),versionOriginal:e.version}}createBar(e,i,s,t,n){const o=new Date(i).getTime(),a=new Date(s).getTime(),l=new Date(this.minYear,0,1).getTime(),h=new Date(this.maxYear,11,31).getTime()-l,r=this.el("div",`bar-segment ${t}`);r.style.left=`${(o-l)/h*100}%`,r.style.width=`${(a-o)/h*100}%`,r.setAttribute("role","img"),r.setAttribute("aria-label",`${e.version} ${n}: ${i} to ${s}`),r.tabIndex=0;const u=`
11
+ <div class="tooltip-header">${n} - ${e.version}</div>
12
+ <div class="tooltip-date"><strong>Du:</strong> ${i}</div>
13
+ <div class="tooltip-date"><strong>Au:</strong> ${s}</div>
14
+ `;return r.onmouseenter=d=>this.showTooltip(d,u),r.onmousemove=d=>this.updateTooltipPos(d),r.onmouseleave=()=>this.hideTooltip(),r.onfocus=d=>{const g=r.getBoundingClientRect();this.showTooltip({pageX:g.left+window.scrollX,pageY:g.top+window.scrollY-40},u)},r.onblur=()=>this.hideTooltip(),r}updateVisibility(){const e=this.rows.filter(t=>t.version.includes(this.filterText));this.rows.forEach(t=>{const n=t.el.querySelector(".version-label"),o=n.querySelector(".version-link");o?o.innerHTML=this.highlight(t.versionOriginal||t.version):n.innerHTML=this.highlight(t.versionOriginal||t.version),t.el.classList.remove("row-visible"),t.el.classList.add("row-hidden")});const i=this.filterText===""&&e.length>this.visibleCount;if((i&&!this.isExpanded?e.slice(0,this.visibleCount):e).forEach(t=>{t.el.classList.remove("row-hidden"),t.el.classList.add("row-visible")}),this.showTable&&this.updateTableVisibility(),this.toggleContainer.innerHTML="",i){const t=this.el("button","timeline-toggle-btn",this.toggleContainer),n=`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="${this.isExpanded?"18 15 12 9 6 15":"6 9 12 15 18 9"}"></polyline></svg>`;t.innerHTML=this.isExpanded?`${this.t("less")} ${n}`:`${this.t("more",{n:e.length-this.visibleCount})} ${n}`,t.setAttribute("aria-expanded",this.isExpanded),t.onclick=()=>{this.isExpanded=!this.isExpanded,this.updateVisibility()}}}renderCurrentDateLine(){const e=new Date(this.minYear,0,1).getTime(),i=new Date(this.maxYear,11,31).getTime()-e,s=Date.now()-e;if(s<0||s>i)return;const t=new Date().toISOString().split("T")[0],n=this.el("div","current-date-indicator",this.indicators);n.style.left=`${s/i*100}%`,n.setAttribute("role","separator"),n.setAttribute("aria-label",this.t("today",{date:t}));const o=this.el("div","current-date-badge",n);o.textContent=t,o.setAttribute("aria-hidden","true")}}return p}));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lifecycle-timeline",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "A beautiful, premium JS timeline component for visualizing product lifecycles, OSS support, and enterprise support dates.",
5
5
  "type": "module",
6
6
  "main": "./dist/timeline.umd.cjs",
@@ -22,7 +22,7 @@
22
22
  "dev": "vite",
23
23
  "build": "vite build",
24
24
  "preview": "vite preview",
25
- "test": "echo \"Error: no test specified\" && exit 1",
25
+ "test": "vitest run",
26
26
  "prepublishOnly": "npm run build",
27
27
  "release": "npm run build && npm publish"
28
28
  },
@@ -34,8 +34,17 @@
34
34
  "support",
35
35
  "oss",
36
36
  "enterprise",
37
+ "js",
38
+ "javascript",
39
+ "react",
37
40
  "vanilla-js",
38
- "premium-ui"
41
+ "premium-ui",
42
+ "accessibility",
43
+ "i18n",
44
+ "responsive",
45
+ "product-management",
46
+ "visualizer",
47
+ "support-lifecycle"
39
48
  ],
40
49
  "author": "Eric REBOISSON",
41
50
  "license": "MIT",
@@ -48,6 +57,9 @@
48
57
  },
49
58
  "homepage": "https://github.com/ericreboisson/lifecycle-timeline#readme",
50
59
  "devDependencies": {
51
- "vite": "^7.3.0"
60
+ "@testing-library/dom": "^10.4.1",
61
+ "jsdom": "^27.4.0",
62
+ "vite": "^7.3.0",
63
+ "vitest": "^4.0.16"
52
64
  }
53
65
  }
package/src/index.d.ts CHANGED
@@ -18,6 +18,17 @@ export interface TimelineOptions {
18
18
  * Defaults to browser language.
19
19
  */
20
20
  locale?: 'en' | 'fr' | string;
21
+
22
+ /**
23
+ * Custom translations to merge or override.
24
+ */
25
+ i18n?: Record<string, Record<string, string>>;
26
+
27
+ /**
28
+ * Whether to show the data table between filter and timeline.
29
+ * @default true
30
+ */
31
+ showTable?: boolean;
21
32
  }
22
33
 
23
34
  export default class Timeline {
@@ -30,9 +41,20 @@ export default class Timeline {
30
41
  constructor(elementId: string, data: TimelineVersion[], options?: TimelineOptions);
31
42
 
32
43
  /**
33
- * Initializes the timeline. Called automatically by constructor.
44
+ * Sets up the initial layout.
45
+ */
46
+ setupBaseLayout(): void;
47
+
48
+ /**
49
+ * Updates the timeline data and re-renders.
50
+ * @param newData Array of version data.
51
+ */
52
+ updateData(newData: TimelineVersion[]): void;
53
+
54
+ /**
55
+ * Renders the entire timeline.
34
56
  */
35
- init(): void;
57
+ render(): void;
36
58
 
37
59
  /**
38
60
  * Renders the toolbar (search/filter).