@5even7/dlc-ui 0.2.0 → 0.2.2

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/dist/progress.mjs CHANGED
@@ -154,41 +154,246 @@ function getPreset(kind, ref) {
154
154
  return found;
155
155
  }
156
156
 
157
- const DEFAULTS = {
158
- progress: {
159
- width: 454,
160
- height: 104,
161
- min: 0,
157
+ const DEFAULTS = {
158
+ progress: {
159
+ width: 454,
160
+ height: 104,
161
+ min: 0,
162
162
  max: 100,
163
- draggable: true,
164
- keyboard: true,
165
- edgeStyle: 'flow',
166
- quality: 'auto',
167
- renderer: 'auto',
168
- showCopy: true,
169
- respectReducedMotion: true
170
- }
163
+ draggable: true,
164
+ keyboard: true,
165
+ disabled: false,
166
+ valueSuffix: '%',
167
+ edgeStyle: 'flow',
168
+ quality: 'auto',
169
+ renderer: 'auto',
170
+ textRatio: 54,
171
+ showValue: true,
172
+ respectReducedMotion: true
173
+ }
171
174
  };
172
175
 
173
- /**
174
- * All user-facing copy lives here so wording/brand changes never require a
175
- * global search. Templates use {brand} {code} {name} placeholders.
176
- */
177
- const COPY = {
178
- brandName: '画境观屿',
179
- dragLabel: 'DRAG',
180
- valueSuffix: '%',
176
+ /**
177
+ * Internal accessibility labels and small UI text. Since the copy API was
178
+ * removed (slots own the text area), these are no longer user-overridable.
179
+ * Templates use {brand} {code} {name} placeholders.
180
+ */
181
+ const COPY = {
182
+ brandName: '画境观屿',
183
+ valueSuffix: '%',
181
184
  progressAria: '{brand} {code} 加载进度',
182
185
  capsuleAria: '打开 {name} 沉浸预览'
183
186
  };
184
187
 
185
- function hexToRgba(hex, alpha = 1) {
186
- const normalized = hex.replace('#', '');
187
- const value = Number.parseInt(normalized, 16);
188
- const red = (value >> 16) & 255;
189
- const green = (value >> 8) & 255;
190
- const blue = value & 255;
191
- return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
188
+ /**
189
+ * Color helpers. `normalizeColor` converts any supported CSS color
190
+ * (hex / named / rgb() / rgba()) into a 6-digit hex string, so renderers can
191
+ * keep working with #rrggbb only. rgba() alpha is intentionally dropped:
192
+ * the WebGL shader has no per-color alpha channel, and the component is
193
+ * opaque by design — use component-level `opacity` for transparency.
194
+ */
195
+
196
+ const NAMED_COLORS = {
197
+ aliceblue: '#f0f8ff',
198
+ antiquewhite: '#faebd7',
199
+ aqua: '#00ffff',
200
+ aquamarine: '#7fffd4',
201
+ azure: '#f0ffff',
202
+ beige: '#f5f5dc',
203
+ bisque: '#ffe4c4',
204
+ black: '#000000',
205
+ blanchedalmond: '#ffebcd',
206
+ blue: '#0000ff',
207
+ blueviolet: '#8a2be2',
208
+ brown: '#a52a2a',
209
+ burlywood: '#deb887',
210
+ cadetblue: '#5f9ea0',
211
+ chartreuse: '#7fff00',
212
+ chocolate: '#d2691e',
213
+ coral: '#ff7f50',
214
+ cornflowerblue: '#6495ed',
215
+ cornsilk: '#fff8dc',
216
+ crimson: '#dc143c',
217
+ cyan: '#00ffff',
218
+ darkblue: '#00008b',
219
+ darkcyan: '#008b8b',
220
+ darkgoldenrod: '#b8860b',
221
+ darkgray: '#a9a9a9',
222
+ darkgreen: '#006400',
223
+ darkgrey: '#a9a9a9',
224
+ darkkhaki: '#bdb76b',
225
+ darkmagenta: '#8b008b',
226
+ darkolivegreen: '#556b2f',
227
+ darkorange: '#ff8c00',
228
+ darkorchid: '#9932cc',
229
+ darkred: '#8b0000',
230
+ darksalmon: '#e9967a',
231
+ darkseagreen: '#8fbc8f',
232
+ darkslateblue: '#483d8b',
233
+ darkslategray: '#2f4f4f',
234
+ darkslategrey: '#2f4f4f',
235
+ darkturquoise: '#00ced1',
236
+ darkviolet: '#9400d3',
237
+ deeppink: '#ff1493',
238
+ deepskyblue: '#00bfff',
239
+ dimgray: '#696969',
240
+ dimgrey: '#696969',
241
+ dodgerblue: '#1e90ff',
242
+ firebrick: '#b22222',
243
+ floralwhite: '#fffaf0',
244
+ forestgreen: '#228b22',
245
+ fuchsia: '#ff00ff',
246
+ gainsboro: '#dcdcdc',
247
+ ghostwhite: '#f8f8ff',
248
+ gold: '#ffd700',
249
+ goldenrod: '#daa520',
250
+ gray: '#808080',
251
+ green: '#008000',
252
+ greenyellow: '#adff2f',
253
+ grey: '#808080',
254
+ honeydew: '#f0fff0',
255
+ hotpink: '#ff69b4',
256
+ indianred: '#cd5c5c',
257
+ indigo: '#4b0082',
258
+ ivory: '#fffff0',
259
+ khaki: '#f0e68c',
260
+ lavender: '#e6e6fa',
261
+ lavenderblush: '#fff0f5',
262
+ lawngreen: '#7cfc00',
263
+ lemonchiffon: '#fffacd',
264
+ lightblue: '#add8e6',
265
+ lightcoral: '#f08080',
266
+ lightcyan: '#e0ffff',
267
+ lightgoldenrodyellow: '#fafad2',
268
+ lightgray: '#d3d3d3',
269
+ lightgreen: '#90ee90',
270
+ lightgrey: '#d3d3d3',
271
+ lightpink: '#ffb6c1',
272
+ lightsalmon: '#ffa07a',
273
+ lightseagreen: '#20b2aa',
274
+ lightskyblue: '#87cefa',
275
+ lightslategray: '#778899',
276
+ lightslategrey: '#778899',
277
+ lightsteelblue: '#b0c4de',
278
+ lightyellow: '#ffffe0',
279
+ lime: '#00ff00',
280
+ limegreen: '#32cd32',
281
+ linen: '#faf0e6',
282
+ magenta: '#ff00ff',
283
+ maroon: '#800000',
284
+ mediumaquamarine: '#66cdaa',
285
+ mediumblue: '#0000cd',
286
+ mediumorchid: '#ba55d3',
287
+ mediumpurple: '#9370db',
288
+ mediumseagreen: '#3cb371',
289
+ mediumslateblue: '#7b68ee',
290
+ mediumspringgreen: '#00fa9a',
291
+ mediumturquoise: '#48d1cc',
292
+ mediumvioletred: '#c71585',
293
+ midnightblue: '#191970',
294
+ mintcream: '#f5fffa',
295
+ mistyrose: '#ffe4e1',
296
+ moccasin: '#ffe4b5',
297
+ navajowhite: '#ffdead',
298
+ navy: '#000080',
299
+ oldlace: '#fdf5e6',
300
+ olive: '#808000',
301
+ olivedrab: '#6b8e23',
302
+ orange: '#ffa500',
303
+ orangered: '#ff4500',
304
+ orchid: '#da70d6',
305
+ palegoldenrod: '#eee8aa',
306
+ palegreen: '#98fb98',
307
+ paleturquoise: '#afeeee',
308
+ palevioletred: '#db7093',
309
+ papayawhip: '#ffefd5',
310
+ peachpuff: '#ffdab9',
311
+ peru: '#cd853f',
312
+ pink: '#ffc0cb',
313
+ plum: '#dda0dd',
314
+ powderblue: '#b0e0e6',
315
+ purple: '#800080',
316
+ rebeccapurple: '#663399',
317
+ red: '#ff0000',
318
+ rosybrown: '#bc8f8f',
319
+ royalblue: '#4169e1',
320
+ saddlebrown: '#8b4513',
321
+ salmon: '#fa8072',
322
+ sandybrown: '#f4a460',
323
+ seagreen: '#2e8b57',
324
+ seashell: '#fff5ee',
325
+ sienna: '#a0522d',
326
+ silver: '#c0c0c0',
327
+ skyblue: '#87ceeb',
328
+ slateblue: '#6a5acd',
329
+ slategray: '#708090',
330
+ slategrey: '#708090',
331
+ snow: '#fffafa',
332
+ springgreen: '#00ff7f',
333
+ steelblue: '#4682b4',
334
+ tan: '#d2b48c',
335
+ teal: '#008080',
336
+ thistle: '#d8bfd8',
337
+ tomato: '#ff6347',
338
+ turquoise: '#40e0d0',
339
+ violet: '#ee82ee',
340
+ wheat: '#f5deb3',
341
+ white: '#ffffff',
342
+ whitesmoke: '#f5f5f5',
343
+ yellow: '#ffff00',
344
+ yellowgreen: '#9acd32'
345
+ };
346
+
347
+ function clampChannel(value) {
348
+ return Math.min(255, Math.max(0, Math.round(value)));
349
+ }
350
+
351
+ function normalizeRgbArgs(args) {
352
+ const parts = args.split(/[,\s/]+/).filter(Boolean);
353
+ if (parts.length < 3) return null;
354
+ const to255 = (value) => {
355
+ if (typeof value !== 'string' || !value) return null;
356
+ if (value.endsWith('%')) return clampChannel((parseFloat(value) / 100) * 255);
357
+ const number = Number(value);
358
+ return Number.isFinite(number) ? clampChannel(number) : null;
359
+ };
360
+ const red = to255(parts[0]);
361
+ const green = to255(parts[1]);
362
+ const blue = to255(parts[2]);
363
+ if (red === null || green === null || blue === null) return null;
364
+ return `#${[red, green, blue].map((n) => n.toString(16).padStart(2, '0')).join('')}`;
365
+ }
366
+
367
+ /**
368
+ * Convert any supported CSS color into `#rrggbb`. Returns null when the
369
+ * value cannot be parsed. Supports: #rgb / #rrggbb (case-insensitive),
370
+ * CSS named colors, rgb() / rgba() with comma or modern space syntax,
371
+ * including percentages. Alpha in rgba() is ignored.
372
+ */
373
+ function normalizeColor(value) {
374
+ if (typeof value !== 'string') return null;
375
+ const input = value.trim();
376
+ if (!input) return null;
377
+ const lower = input.toLowerCase();
378
+ if (NAMED_COLORS[lower]) return NAMED_COLORS[lower];
379
+ if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(input)) {
380
+ const hex = input.slice(1).toLowerCase();
381
+ if (hex.length === 3) return `#${hex.split('').map((c) => c + c).join('')}`;
382
+ return `#${hex}`;
383
+ }
384
+ const match = input.match(/^rgba?\((.*)\)$/i);
385
+ if (match) return normalizeRgbArgs(match[1]);
386
+ return null;
387
+ }
388
+
389
+ function hexToRgba(color, alpha = 1) {
390
+ const hex = normalizeColor(color);
391
+ if (!hex) return 'rgba(0, 0, 0, 0)';
392
+ const value = Number.parseInt(hex.slice(1), 16);
393
+ const red = (value >> 16) & 255;
394
+ const green = (value >> 8) & 255;
395
+ const blue = value & 255;
396
+ return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
192
397
  }
193
398
 
194
399
  /**
@@ -894,6 +1099,22 @@ function attachProgressFlowOverlay({ root, canvas, preset, getProgress }) {
894
1099
  };
895
1100
  }
896
1101
 
1102
+ /**
1103
+ * Run a callback asynchronously, right after the current task and before the
1104
+ * next paint. Used for mount-time events (ready / error) so listeners that
1105
+ * are attached after create() still receive them. Falls back for legacy
1106
+ * browsers that lack queueMicrotask / Promise.
1107
+ */
1108
+ function nextTick(fn) {
1109
+ if (typeof queueMicrotask === 'function') {
1110
+ queueMicrotask(fn);
1111
+ } else if (typeof Promise !== 'undefined' && typeof Promise.resolve === 'function') {
1112
+ Promise.resolve().then(fn);
1113
+ } else {
1114
+ setTimeout(fn, 0);
1115
+ }
1116
+ }
1117
+
897
1118
  const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
898
1119
  const SUPPORTS_CTX_FILTER = typeof CanvasRenderingContext2D !== 'undefined' && 'filter' in CanvasRenderingContext2D.prototype;
899
1120
 
@@ -973,14 +1194,16 @@ const FLOW_PROFILES = {
973
1194
  };
974
1195
 
975
1196
  class ProgressCapsuleController {
976
- constructor({ root, canvas, valueElement, preset, emitter, options, copy }) {
1197
+ constructor({ root, canvas, valueElement, preset, emitter, options, copy, dirty }) {
977
1198
  this.root = root;
978
1199
  this.canvas = canvas;
979
1200
  this.valueElement = valueElement;
980
1201
  this.preset = preset;
981
1202
  this.emitter = emitter;
982
- this.options = options;
983
- this.copy = copy;
1203
+ this.options = options;
1204
+ this.copy = copy;
1205
+ this.dirty = dirty;
1206
+ this.valueSuffix = options.valueSuffix ?? copy.valueSuffix;
984
1207
  this.profile = preset.edgeStyle === 'tide'
985
1208
  ? FLOW_PROFILES.tide
986
1209
  : (FLOW_PROFILES[preset.id] || FLOW_PROFILES['visual-training']);
@@ -1021,11 +1244,22 @@ class ProgressCapsuleController {
1021
1244
  this.value = clamp(nextValue, this.min, this.max);
1022
1245
  const rounded = Math.round(this.value);
1023
1246
  this.root.style.setProperty('--progress', this.value.toFixed(2));
1024
- this.root.setAttribute('aria-valuenow', String(rounded));
1025
- this.root.setAttribute('aria-valuetext', `${rounded}${this.copy.valueSuffix}`);
1026
- this.valueElement.textContent = `${rounded}${this.copy.valueSuffix}`;
1027
- if (!this.suppressEvents) this.emitter.emit('change', { value: this.value, source });
1028
- }
1247
+ this.root.setAttribute('aria-valuenow', String(rounded));
1248
+ this.root.setAttribute('aria-valuetext', `${rounded}${this.valueSuffix}`);
1249
+ if (this.valueElement) this.valueElement.textContent = `${rounded}${this.valueSuffix}`;
1250
+ if (!this.suppressEvents) {
1251
+ if (this.dirty) this.dirty.value = true;
1252
+ this.emitter.emit('change', { value: this.value, source });
1253
+ }
1254
+ }
1255
+
1256
+ setValueSuffix(suffix) {
1257
+ this.valueSuffix = String(suffix == null ? '' : suffix);
1258
+ const rounded = Math.round(this.value);
1259
+ if (this.valueElement) this.valueElement.textContent = `${rounded}${this.valueSuffix}`;
1260
+ this.root.setAttribute('aria-valuetext', `${rounded}${this.valueSuffix}`);
1261
+ return this;
1262
+ }
1029
1263
 
1030
1264
  resizeCanvas() {
1031
1265
  const bounds = this.root.getBoundingClientRect();
@@ -1198,13 +1432,14 @@ class ProgressCapsuleController {
1198
1432
  if (clamped !== this.value) this.setProgress(clamped, 'prop');
1199
1433
  }
1200
1434
 
1201
- drawReferenceFlow() {
1435
+ drawReferenceFlow() {
1202
1436
  const ctx = this.ctx;
1203
1437
  const width = this.width;
1204
1438
  const height = this.height;
1205
1439
  if (!ctx || width <= 0 || height <= 0) return;
1206
1440
 
1207
- const shoreline = width * (this.value / 100);
1441
+ const range = Math.max(this.max - this.min, 1);
1442
+ const shoreline = width * Math.min(Math.max((this.value - this.min) / range, 0), 1);
1208
1443
  const time = this.flowTime;
1209
1444
  const [dark, accentA, accentB, glow] = this.preset.colors;
1210
1445
 
@@ -1322,32 +1557,49 @@ class ProgressCapsuleController {
1322
1557
  }
1323
1558
  }
1324
1559
 
1325
- updateFromPointer(event) {
1326
- const bounds = this.root.getBoundingClientRect();
1327
- const ratio = bounds.width > 0 ? (event.clientX - bounds.left) / bounds.width : 0;
1328
- this.setProgress(ratio * 100, 'drag');
1329
- }
1330
-
1331
- beginDrag(event) {
1332
- if (event.button !== undefined && event.button !== 0) return;
1333
- event.preventDefault();
1334
- this.dragging = true;
1335
- this.root.classList.add('is-dragging');
1336
- try { this.root.setPointerCapture?.(event.pointerId); } catch {}
1337
- this.emitter.emit('dragstart', { value: this.value });
1338
- this.updateFromPointer(event);
1339
- }
1340
-
1341
- moveDrag(event) {
1342
- if (!this.dragging) return;
1343
- event.preventDefault();
1344
- this.updateFromPointer(event);
1345
- }
1346
-
1347
- endDrag(event) {
1348
- if (!this.dragging) return;
1349
- this.dragging = false;
1350
- this.root.classList.remove('is-dragging');
1560
+ updateFromPointer(event) {
1561
+ const bounds = this.root.getBoundingClientRect();
1562
+ const ratio = bounds.width > 0 ? (event.clientX - bounds.left) / bounds.width : 0;
1563
+ this.setProgress(this.min + ratio * (this.max - this.min), 'drag');
1564
+ }
1565
+
1566
+ beginDrag(event) {
1567
+ if (event.button !== undefined && event.button !== 0) return;
1568
+ event.preventDefault();
1569
+ this.dragging = true;
1570
+ this.pendingPointer = null;
1571
+ this._dragRaf = 0;
1572
+ this.root.classList.add('is-dragging');
1573
+ try { this.root.setPointerCapture?.(event.pointerId); } catch {}
1574
+ this.emitter.emit('dragstart', { value: this.value });
1575
+ this.updateFromPointer(event);
1576
+ }
1577
+
1578
+ moveDrag(event) {
1579
+ if (!this.dragging) return;
1580
+ event.preventDefault();
1581
+ // rAF 合并:一帧最多一次 setProgress/change,视觉仍每帧更新。
1582
+ this.pendingPointer = event;
1583
+ if (this._dragRaf) return;
1584
+ this._dragRaf = requestAnimationFrame(() => {
1585
+ this._dragRaf = 0;
1586
+ const pending = this.pendingPointer;
1587
+ this.pendingPointer = null;
1588
+ if (pending) this.updateFromPointer(pending);
1589
+ });
1590
+ }
1591
+
1592
+ endDrag(event) {
1593
+ if (!this.dragging) return;
1594
+ this.dragging = false;
1595
+ if (this._dragRaf) {
1596
+ cancelAnimationFrame(this._dragRaf);
1597
+ this._dragRaf = 0;
1598
+ }
1599
+ const pending = this.pendingPointer;
1600
+ this.pendingPointer = null;
1601
+ if (pending) this.updateFromPointer(pending);
1602
+ this.root.classList.remove('is-dragging');
1351
1603
  try {
1352
1604
  if (event?.pointerId !== undefined && this.root.hasPointerCapture?.(event.pointerId)) {
1353
1605
  this.root.releasePointerCapture(event.pointerId);
@@ -1369,15 +1621,16 @@ class ProgressCapsuleController {
1369
1621
  }
1370
1622
 
1371
1623
  if (this.options.keyboard !== false) {
1372
- this.handlers.keydown = (event) => {
1373
- const actions = {
1374
- ArrowLeft: -2,
1375
- ArrowDown: -2,
1376
- ArrowRight: 2,
1377
- ArrowUp: 2,
1378
- PageDown: -10,
1379
- PageUp: 10
1380
- };
1624
+ this.handlers.keydown = (event) => {
1625
+ const step = (this.max - this.min) * 0.02;
1626
+ const actions = {
1627
+ ArrowLeft: -step,
1628
+ ArrowDown: -step,
1629
+ ArrowRight: step,
1630
+ ArrowUp: step,
1631
+ PageDown: -step * 5,
1632
+ PageUp: step * 5
1633
+ };
1381
1634
  if (event.key === 'Home') {
1382
1635
  event.preventDefault();
1383
1636
  this.setProgress(this.min, 'keyboard');
@@ -1415,8 +1668,9 @@ class ProgressCapsuleController {
1415
1668
  this.preset = { ...this.preset, colors };
1416
1669
  }
1417
1670
 
1418
- dispose() {
1419
- if (this.resizeObserver) this.resizeObserver.disconnect();
1671
+ dispose() {
1672
+ if (this._dragRaf) cancelAnimationFrame(this._dragRaf);
1673
+ if (this.resizeObserver) this.resizeObserver.disconnect();
1420
1674
  else window.removeEventListener('resize', this.resizeCanvas);
1421
1675
  for (const name of Object.keys(this.handlers)) {
1422
1676
  const handler = this.handlers[name];
@@ -1427,103 +1681,127 @@ class ProgressCapsuleController {
1427
1681
  }
1428
1682
 
1429
1683
  /**
1430
- * Mount a fluid progress capsule into `container`.
1431
- *
1432
- * Options: preset (id/code/name or object), width, height, value, min, max,
1433
- * draggable, keyboard, colors, edgeStyle, quality,
1434
- * respectReducedMotion, copy, cssVars.
1435
- */
1436
- function createProgressCapsule(container, options = {}) {
1684
+ * Mount a fluid progress capsule into `container`.
1685
+ *
1686
+ * Options: preset (id/code/name or object), width, height, value, min, max,
1687
+ * draggable, keyboard, colors, edgeStyle, textRatio (0-100, text region
1688
+ * width in percent), text (HTML string or DOM nodes for the text slot),
1689
+ * colorContent (HTML string or DOM nodes for the color slot),
1690
+ * showValue (show/hide the right-side percentage), quality,
1691
+ * respectReducedMotion, cssVars.
1692
+ */
1693
+ function createProgressCapsule(container, options = {}) {
1437
1694
  if (!container || typeof container.appendChild !== 'function') {
1438
1695
  throw new Error('createProgressCapsule: container element is required');
1439
1696
  }
1440
1697
 
1441
1698
  const preset = { ...getPreset('progress', options.preset ?? '星火') };
1442
- const merged = normalizeOptions(DEFAULTS.progress, preset, options);
1443
- const copy = { ...COPY, ...(merged.copy || {}) };
1444
-
1445
- const root = document.createElement('div');
1446
- root.className = 'hj-capsule-root hj-progress-root';
1447
- root.setAttribute('role', 'slider');
1448
- root.setAttribute('tabindex', '0');
1449
- root.setAttribute('data-draggable', String(merged.draggable !== false));
1699
+ const merged = normalizeOptions(DEFAULTS.progress, preset, options);
1700
+ const normalizedColors = merged.colors.map(normalizeColor);
1701
+ if (normalizedColors.every(Boolean)) preset.colors = normalizedColors;
1702
+ preset.edgeStyle = merged.edgeStyle;
1703
+ const copy = COPY;
1704
+ let customLabel = typeof options.label === 'string' && options.label ? options.label : null;
1705
+ // Vue 的裸布尔属性(<ProgressCapsule disabled />)会传成空字符串,
1706
+ // 这里统一按 true 处理。
1707
+ const disabled = merged.disabled === true || merged.disabled === '' || merged.disabled === 'true';
1708
+ const effectiveDraggable = disabled ? false : merged.draggable !== false;
1709
+ const effectiveKeyboard = disabled ? false : merged.keyboard !== false;
1710
+ const dirty = {
1711
+ preset: false,
1712
+ colors: false,
1713
+ textRatio: false,
1714
+ cssVars: false,
1715
+ edgeStyle: false,
1716
+ value: false
1717
+ };
1718
+
1719
+ const root = document.createElement('div');
1720
+ root.className = 'hj-capsule-root hj-progress-root';
1721
+ root.setAttribute('role', 'slider');
1722
+ root.setAttribute('tabindex', '0');
1723
+ root.setAttribute('data-draggable', String(effectiveDraggable));
1724
+ if (disabled) {
1725
+ root.setAttribute('aria-disabled', 'true');
1726
+ root.classList.add('is-disabled');
1727
+ }
1450
1728
  root.setAttribute('aria-valuemin', String(merged.min ?? 0));
1451
1729
  root.setAttribute('aria-valuemax', String(merged.max ?? 100));
1452
1730
  root.setAttribute('aria-valuenow', String(preset.initialProgress));
1453
- root.setAttribute(
1454
- 'aria-label',
1455
- interpolate(copy.progressAria, { brand: copy.brandName, code: preset.code, name: preset.name })
1456
- );
1457
- if (merged.keyboard === false) root.setAttribute('tabindex', '-1');
1731
+ const updateAria = () => {
1732
+ root.setAttribute(
1733
+ 'aria-label',
1734
+ customLabel || interpolate(copy.progressAria, { brand: copy.brandName, code: preset.code, name: preset.name })
1735
+ );
1736
+ };
1737
+ updateAria();
1738
+ if (!effectiveKeyboard) root.setAttribute('tabindex', '-1');
1458
1739
 
1459
1740
  const canvas = document.createElement('canvas');
1460
1741
  canvas.className = 'hj-progress-canvas';
1461
1742
  canvas.setAttribute('aria-hidden', 'true');
1462
1743
 
1463
- const copyEnabled = copy.enabled !== false && merged.showCopy !== false;
1464
-
1465
- const copyText = (key, fallback) => {
1466
- const value = copy[key];
1467
- return value === undefined ? fallback : value;
1468
- };
1469
-
1470
- function buildCopyHtml() {
1471
- let html = '';
1472
- const brandText = copy.brandName || '';
1473
- const subtitleText = copyText('subtitle', preset.subtitle);
1474
- if (brandText) html += `<span class="hj-progress-name">${brandText}</span>`;
1475
- if (subtitleText) html += `<span class="hj-progress-subtitle">${subtitleText}</span>`;
1476
- return html;
1477
- }
1478
-
1479
- const copyLayer = document.createElement('div');
1480
- copyLayer.className = 'hj-progress-copy';
1481
- const renderCopy = () => {
1482
- if (copyEnabled) copyLayer.innerHTML = buildCopyHtml();
1483
- };
1484
- renderCopy();
1485
-
1486
- if (copy.background) {
1487
- root.style.setProperty('--hj-progress-copy-bg', copy.background);
1488
- root.style.setProperty('--hj-progress-copy-pad', '6px 12px');
1489
- }
1490
- if (copy.color) root.style.setProperty('--hj-progress-copy-color', copy.color);
1491
-
1492
- const valueElement = document.createElement('div');
1493
- valueElement.className = 'hj-progress-value';
1494
- valueElement.setAttribute('aria-hidden', 'true');
1495
-
1496
- const dragLabel = document.createElement('span');
1497
- dragLabel.className = 'hj-progress-drag-label';
1498
- dragLabel.setAttribute('aria-hidden', 'true');
1499
- dragLabel.textContent = copy.dragLabel;
1500
-
1501
- root.appendChild(canvas);
1502
- if (copyEnabled) root.appendChild(copyLayer);
1503
- root.appendChild(valueElement);
1504
- root.appendChild(dragLabel);
1505
- container.appendChild(root);
1506
-
1507
- const emitter = createEmitter();
1508
- let paused = merged.respectReducedMotion && prefersReducedMotion();
1509
-
1510
- const applySize = () => {
1511
- root.style.width = parseSize(merged.width);
1512
- root.style.height = parseSize(merged.height);
1513
- const vars = merged.cssVars || {};
1514
- for (const name of Object.keys(vars)) root.style.setProperty(name, vars[name]);
1515
- };
1744
+ // Slot containers are always present but transparent by default; they only
1745
+ // provide geometry, never typography/padding/background (docs: 插槽 CSS 约定).
1746
+ const fillContent = (layer, content) => {
1747
+ layer.innerHTML = '';
1748
+ if (content == null) return;
1749
+ if (typeof content === 'string') {
1750
+ layer.innerHTML = content;
1751
+ return;
1752
+ }
1753
+ const nodes = Array.isArray(content) ? content : [content];
1754
+ for (const node of nodes) {
1755
+ if (node && typeof node.nodeType === 'number') layer.appendChild(node);
1756
+ }
1757
+ };
1758
+
1759
+ const textLayer = document.createElement('div');
1760
+ textLayer.className = 'hj-progress-text hj-progress-copy';
1761
+
1762
+ const visualLayer = document.createElement('div');
1763
+ visualLayer.className = 'hj-progress-visual';
1764
+
1765
+ fillContent(textLayer, options.text);
1766
+ fillContent(visualLayer, options.colorContent);
1767
+
1768
+ const valueElement = merged.showValue === false ? null : document.createElement('div');
1769
+ if (valueElement) {
1770
+ valueElement.className = 'hj-progress-value';
1771
+ valueElement.setAttribute('aria-hidden', 'true');
1772
+ }
1773
+
1774
+ root.appendChild(canvas);
1775
+ root.appendChild(textLayer);
1776
+ root.appendChild(visualLayer);
1777
+ if (valueElement) root.appendChild(valueElement);
1778
+ container.appendChild(root);
1779
+
1780
+ const emitter = createEmitter();
1781
+ let paused = merged.respectReducedMotion && prefersReducedMotion();
1782
+ let disposed = false;
1783
+
1784
+ const applySize = () => {
1785
+ root.style.width = parseSize(merged.width);
1786
+ root.style.height = parseSize(merged.height);
1787
+ const vars = merged.cssVars || {};
1788
+ for (const name of Object.keys(vars)) root.style.setProperty(name, vars[name]);
1789
+ if (merged.textRatio !== undefined) {
1790
+ root.style.setProperty('--hj-text-width', `${merged.textRatio}%`);
1791
+ }
1792
+ };
1516
1793
  applySize();
1517
1794
 
1518
- const controller = new ProgressCapsuleController({
1519
- root,
1520
- canvas,
1521
- valueElement,
1522
- preset,
1523
- emitter,
1524
- options: merged,
1525
- copy
1526
- });
1795
+ const controller = new ProgressCapsuleController({
1796
+ root,
1797
+ canvas,
1798
+ valueElement,
1799
+ preset,
1800
+ emitter,
1801
+ options: { ...merged, draggable: effectiveDraggable, keyboard: effectiveKeyboard },
1802
+ copy,
1803
+ dirty
1804
+ });
1527
1805
 
1528
1806
  let overlay = null;
1529
1807
  if (merged.renderer !== 'canvas2d') {
@@ -1534,7 +1812,13 @@ function createProgressCapsule(container, options = {}) {
1534
1812
  getProgress: () => controller.value
1535
1813
  });
1536
1814
  }
1537
- if (overlay) controller.webglActive = true;
1815
+ if (overlay) controller.webglActive = true;
1816
+ else if (merged.renderer !== 'canvas2d') {
1817
+ nextTick(() => {
1818
+ if (disposed) return;
1819
+ emitter.emit('error', { message: 'WebGL2 unavailable, using Canvas2D fallback' });
1820
+ });
1821
+ }
1538
1822
 
1539
1823
  const visibility = createVisibilityGuard(root);
1540
1824
  let flowTime = 0;
@@ -1550,15 +1834,14 @@ function createProgressCapsule(container, options = {}) {
1550
1834
  () => paused
1551
1835
  );
1552
1836
 
1553
- emitter.emit('ready', { preset: { ...preset } });
1837
+ nextTick(() => {
1838
+ if (disposed) return;
1839
+ emitter.emit('ready', { preset: { ...preset } });
1840
+ });
1554
1841
 
1555
- const syncDom = () => {
1556
- root.setAttribute(
1557
- 'aria-label',
1558
- interpolate(copy.progressAria, { brand: copy.brandName, code: preset.code, name: preset.name })
1559
- );
1560
- renderCopy();
1561
- };
1842
+ const syncDom = () => {
1843
+ updateAria();
1844
+ };
1562
1845
 
1563
1846
  return {
1564
1847
  element: root,
@@ -1579,26 +1862,30 @@ function createProgressCapsule(container, options = {}) {
1579
1862
  },
1580
1863
  setPreset(ref) {
1581
1864
  const next = getPreset('progress', ref);
1865
+ dirty.preset = true;
1582
1866
  Object.assign(preset, next);
1583
- controller.preset = next;
1584
- controller.profile = next.edgeStyle === 'tide'
1585
- ? FLOW_PROFILES.tide
1586
- : (FLOW_PROFILES[next.id] || FLOW_PROFILES['visual-training']);
1587
- controller.flowTime = stringSeed(next.id) * 31;
1588
- controller.seed = stringSeed(`${next.id}-reference`) * Math.PI * 2;
1589
- syncDom();
1590
- if (overlay) {
1591
- overlay.dispose();
1592
- overlay = attachProgressFlowOverlay({ root, canvas, preset: next, getProgress: () => controller.value });
1593
- }
1594
- controller.webglActive = Boolean(overlay);
1595
- controller.resizeCanvas();
1596
- emitter.emit('presetchange', { preset: { ...next } });
1867
+ const nextColors = preset.colors.map(normalizeColor);
1868
+ if (nextColors.every(Boolean)) preset.colors = nextColors;
1869
+ controller.preset = preset;
1870
+ controller.profile = next.edgeStyle === 'tide'
1871
+ ? FLOW_PROFILES.tide
1872
+ : (FLOW_PROFILES[next.id] || FLOW_PROFILES['visual-training']);
1873
+ controller.flowTime = stringSeed(next.id) * 31;
1874
+ controller.seed = stringSeed(`${next.id}-reference`) * Math.PI * 2;
1875
+ syncDom();
1876
+ if (overlay) {
1877
+ overlay.dispose();
1878
+ overlay = attachProgressFlowOverlay({ root, canvas, preset, getProgress: () => controller.value });
1879
+ }
1880
+ controller.webglActive = Boolean(overlay);
1881
+ controller.resizeCanvas();
1882
+ emitter.emit('presetchange', { preset: { ...preset } });
1597
1883
  return this;
1598
1884
  },
1599
1885
  setEdgeStyle(edgeStyle) {
1600
1886
  const value = String(edgeStyle || '').toLowerCase();
1601
1887
  if (value !== 'flow' && value !== 'tide') return this;
1888
+ dirty.edgeStyle = true;
1602
1889
  preset.edgeStyle = value;
1603
1890
  controller.profile = value === 'tide'
1604
1891
  ? FLOW_PROFILES.tide
@@ -1611,12 +1898,46 @@ function createProgressCapsule(container, options = {}) {
1611
1898
  controller.resizeCanvas();
1612
1899
  return this;
1613
1900
  },
1901
+ setText(content) {
1902
+ fillContent(textLayer, content);
1903
+ return this;
1904
+ },
1905
+ setColorContent(content) {
1906
+ fillContent(visualLayer, content);
1907
+ return this;
1908
+ },
1909
+ setTextRatio(ratio) {
1910
+ const value = Number(ratio);
1911
+ if (!Number.isFinite(value) || value < 0 || value > 100) return this;
1912
+ dirty.textRatio = true;
1913
+ merged.textRatio = value;
1914
+ root.style.setProperty('--hj-text-width', `${value}%`);
1915
+ return this;
1916
+ },
1917
+ setCssVars(vars) {
1918
+ if (!vars || typeof vars !== 'object') return this;
1919
+ dirty.cssVars = true;
1920
+ merged.cssVars = { ...(merged.cssVars || {}), ...vars };
1921
+ for (const name of Object.keys(vars)) root.style.setProperty(name, vars[name]);
1922
+ return this;
1923
+ },
1924
+ setLabel(label) {
1925
+ customLabel = typeof label === 'string' && label ? label : null;
1926
+ updateAria();
1927
+ return this;
1928
+ },
1929
+ setValueSuffix(suffix) {
1930
+ controller.setValueSuffix(suffix);
1931
+ return this;
1932
+ },
1614
1933
  setColors(colors) {
1615
- if (!Array.isArray(colors) || colors.length !== 4) return this;
1616
- controller.setColors(colors);
1617
- if (overlay) overlay.setColors(colors);
1618
- controller.resizeCanvas();
1619
- return this;
1934
+ const next = Array.isArray(colors) ? colors.map(normalizeColor) : [];
1935
+ if (next.length !== 4 || next.some((color) => !color)) return this;
1936
+ dirty.colors = true;
1937
+ controller.setColors(next);
1938
+ if (overlay) overlay.setColors(next);
1939
+ controller.resizeCanvas();
1940
+ return this;
1620
1941
  },
1621
1942
  setSize(width, height) {
1622
1943
  if (width !== undefined) merged.width = width;
@@ -1643,14 +1964,18 @@ function createProgressCapsule(container, options = {}) {
1643
1964
  controller.resizeCanvas();
1644
1965
  return this;
1645
1966
  },
1646
- dispose() {
1647
- unsubscribe();
1967
+ dispose() {
1968
+ disposed = true;
1969
+ unsubscribe();
1648
1970
  visibility.dispose();
1649
1971
  if (overlay) overlay.dispose();
1650
- controller.dispose();
1651
- root.remove();
1652
- }
1653
- };
1972
+ controller.dispose();
1973
+ root.remove();
1974
+ },
1975
+ textRatio: merged.textRatio,
1976
+ cssVars: merged.cssVars,
1977
+ dirty
1978
+ };
1654
1979
  }
1655
1980
 
1656
1981
  export { ProgressCapsuleController, createProgressCapsule };