kempo-ui 0.4.2 → 0.4.4

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.
Files changed (104) hide show
  1. package/.claude/skills/get-icon/SKILL.md +144 -0
  2. package/.claude/skills/highlight-code/SKILL.md +62 -0
  3. package/.claude/skills/new-component/SKILL.md +362 -0
  4. package/.github/skills/get-icon/SKILL.md +4 -4
  5. package/.github/skills/highlight-code/SKILL.md +51 -20
  6. package/.github/skills/new-component/SKILL.md +183 -152
  7. package/AGENTS.md +2 -2
  8. package/dist/components/Calendar.js +170 -0
  9. package/dist/components/SpeechToText.js +60 -0
  10. package/dist/components/TextToSpeech.js +56 -0
  11. package/dist/components/Time.js +37 -0
  12. package/docs/components/accordion.html +16 -0
  13. package/docs/components/aside.html +16 -0
  14. package/docs/components/calendar.html +720 -0
  15. package/docs/components/card.html +16 -0
  16. package/docs/components/code-editor.html +16 -0
  17. package/docs/components/color-picker.html +16 -0
  18. package/docs/components/combobox.html +16 -0
  19. package/docs/components/content-slider.html +16 -0
  20. package/docs/components/context.html +16 -0
  21. package/docs/components/dialog.html +16 -0
  22. package/docs/components/dropdown.html +16 -0
  23. package/docs/components/filter-list.html +16 -0
  24. package/docs/components/focus-capture.html +16 -0
  25. package/docs/components/html-editor.html +16 -0
  26. package/docs/components/hybrid-component.html +16 -0
  27. package/docs/components/icon.html +16 -0
  28. package/docs/components/import.html +16 -0
  29. package/docs/components/light-component.html +16 -0
  30. package/docs/components/nav-spacer.html +16 -0
  31. package/docs/components/nav.html +16 -0
  32. package/docs/components/photo-viewer.html +16 -0
  33. package/docs/components/progress.html +16 -0
  34. package/docs/components/resize.html +16 -0
  35. package/docs/components/shadow-component.html +16 -0
  36. package/docs/components/show-more.html +16 -0
  37. package/docs/components/slider.html +16 -0
  38. package/docs/components/sortable.html +16 -0
  39. package/docs/components/speech-to-text.html +717 -0
  40. package/docs/components/spinner.html +16 -0
  41. package/docs/components/split.html +16 -0
  42. package/docs/components/table.html +16 -0
  43. package/docs/components/tableControls.html +16 -0
  44. package/docs/components/tableCustomFields.html +16 -0
  45. package/docs/components/tableFetchRecords.html +16 -0
  46. package/docs/components/tableFieldSortHide.html +16 -0
  47. package/docs/components/tablePagination.html +16 -0
  48. package/docs/components/tablePlaceholder.html +16 -0
  49. package/docs/components/tableRecordEditing.html +16 -0
  50. package/docs/components/tableRecordFiltering.html +16 -0
  51. package/docs/components/tableRecordHiding.html +16 -0
  52. package/docs/components/tableRecordSearching.html +16 -0
  53. package/docs/components/tableRecordSelection.html +16 -0
  54. package/docs/components/tableRowControls.html +16 -0
  55. package/docs/components/tableServerSync.html +16 -0
  56. package/docs/components/tableSorting.html +16 -0
  57. package/docs/components/tabs.html +16 -0
  58. package/docs/components/tags.html +16 -0
  59. package/docs/components/text-to-speech.html +668 -0
  60. package/docs/components/theme-select.html +16 -0
  61. package/docs/components/theme-switcher.html +16 -0
  62. package/docs/components/time.html +670 -0
  63. package/docs/components/timestamp.html +16 -0
  64. package/docs/components/toast.html +16 -0
  65. package/docs/components/toggle.html +16 -0
  66. package/docs/components/tree.html +16 -0
  67. package/docs/icons/mic.svg +1 -0
  68. package/docs/icons/record_voice_over.svg +1 -0
  69. package/docs/icons/stop.svg +1 -0
  70. package/docs/index.html +40 -0
  71. package/docs/src/components/Calendar.js +170 -0
  72. package/docs/src/components/SpeechToText.js +60 -0
  73. package/docs/src/components/TextToSpeech.js +56 -0
  74. package/docs/src/components/Time.js +37 -0
  75. package/docs/utils/context.html +16 -0
  76. package/docs/utils/cookie.html +16 -0
  77. package/docs/utils/debounce.html +16 -0
  78. package/docs/utils/drag.html +16 -0
  79. package/docs/utils/elevation.html +16 -0
  80. package/docs/utils/formatTimestamp.html +16 -0
  81. package/docs/utils/object.html +16 -0
  82. package/docs/utils/propConverters.html +16 -0
  83. package/docs/utils/string.html +16 -0
  84. package/docs/utils/theme.html +16 -0
  85. package/docs/utils/toTitleCase.html +16 -0
  86. package/docs/utils/type.html +16 -0
  87. package/docs/utils/wait.html +16 -0
  88. package/docs-src/components/calendar.page.html +254 -0
  89. package/docs-src/components/speech-to-text.page.html +251 -0
  90. package/docs-src/components/text-to-speech.page.html +202 -0
  91. package/docs-src/components/time.page.html +204 -0
  92. package/docs-src/index.page.html +24 -0
  93. package/docs-src/nav.fragment.html +16 -0
  94. package/icons/mic.svg +1 -0
  95. package/icons/record_voice_over.svg +1 -0
  96. package/icons/stop.svg +1 -0
  97. package/llms.txt +4 -0
  98. package/package.json +1 -1
  99. package/src/components/Calendar.js +473 -0
  100. package/src/components/SpeechToText.js +252 -0
  101. package/src/components/TextToSpeech.js +194 -0
  102. package/src/components/Time.js +137 -0
  103. package/tests/components/SpeechToText.browser-test.js +530 -0
  104. package/tests/components/TextToSpeech.browser-test.js +432 -0
@@ -0,0 +1,473 @@
1
+ import { html, css } from '../lit-all.min.js';
2
+ import ShadowComponent from './ShadowComponent.js';
3
+
4
+ const MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
5
+ const DAY_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
6
+
7
+ export default class Calendar extends ShadowComponent {
8
+ static formAssociated = true;
9
+
10
+ static properties = {
11
+ value: { type: String, reflect: true },
12
+ name: { type: String, reflect: true },
13
+ mode: { type: String, reflect: true },
14
+ min: { type: String, reflect: true },
15
+ max: { type: String, reflect: true },
16
+ disabled: { type: Boolean, reflect: true },
17
+ required: { type: Boolean, reflect: true },
18
+ view: { state: true },
19
+ hoverDate: { state: true }
20
+ };
21
+
22
+ /*
23
+ Lifecycle Callbacks
24
+ */
25
+ constructor() {
26
+ super();
27
+ this.internals = this.attachInternals();
28
+ this.value = '';
29
+ this.name = '';
30
+ this.mode = 'single';
31
+ this.min = '';
32
+ this.max = '';
33
+ this.disabled = false;
34
+ this.required = false;
35
+ const today = new Date();
36
+ this.view = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`;
37
+ this.hoverDate = null;
38
+ }
39
+
40
+ #skipViewSync = false;
41
+
42
+ updated(changedProperties) {
43
+ super.updated(changedProperties);
44
+ this.internals.setFormValue(this.value);
45
+ if(this.required && !this.value){
46
+ this.internals.setValidity(
47
+ { valueMissing: true },
48
+ 'Please select a date.',
49
+ this.shadowRoot?.querySelector('.day')
50
+ );
51
+ } else {
52
+ this.internals.setValidity({});
53
+ }
54
+ if(changedProperties.has('value') && changedProperties.get('value') !== undefined){
55
+ this.dispatchEvent(new CustomEvent('change', {
56
+ detail: { value: this.value },
57
+ bubbles: true
58
+ }));
59
+ }
60
+ if(changedProperties.has('value') && this.value && !this.#skipViewSync){
61
+ const first = this.fromIso(this.value.split(',')[0]);
62
+ if(first){
63
+ const newView = `${first.getFullYear()}-${String(first.getMonth() + 1).padStart(2, '0')}`;
64
+ if(newView !== this.view){
65
+ this.view = newView;
66
+ }
67
+ }
68
+ }
69
+ this.#skipViewSync = false;
70
+ }
71
+
72
+ /*
73
+ Utility
74
+ */
75
+ get isRange() {
76
+ return this.mode === 'range';
77
+ }
78
+
79
+ get viewYear() {
80
+ return Number(this.view.split('-')[0]);
81
+ }
82
+
83
+ get viewMonth() {
84
+ return Number(this.view.split('-')[1]) - 1;
85
+ }
86
+
87
+ get parsed() {
88
+ if(!this.value){
89
+ return this.isRange ? { start: null, end: null } : { date: null };
90
+ }
91
+ if(this.isRange){
92
+ const [s, e] = this.value.split(',');
93
+ return {
94
+ start: s ? this.fromIso(s) : null,
95
+ end: e ? this.fromIso(e) : null
96
+ };
97
+ }
98
+ return { date: this.fromIso(this.value) };
99
+ }
100
+
101
+ toIso = (date) => {
102
+ if(!date) return '';
103
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
104
+ };
105
+
106
+ fromIso = (str) => {
107
+ if(!str) return null;
108
+ const [y, m, d] = str.split('-').map(Number);
109
+ if(isNaN(y) || isNaN(m) || isNaN(d)) return null;
110
+ return new Date(y, m - 1, d);
111
+ };
112
+
113
+ isSameDay = (a, b) => {
114
+ if(!a || !b) return false;
115
+ return a.getFullYear() === b.getFullYear()
116
+ && a.getMonth() === b.getMonth()
117
+ && a.getDate() === b.getDate();
118
+ };
119
+
120
+ isWithinRange = (date, start, end) => {
121
+ if(!start || !end || !date) return false;
122
+ const t = date.getTime();
123
+ return t > start.getTime() && t < end.getTime();
124
+ };
125
+
126
+ resolveDate = (input) => {
127
+ if(!input) return null;
128
+ const key = String(input).toLowerCase();
129
+ if(key === 'today' || key === 'tomorrow' || key === 'yesterday'){
130
+ const d = new Date();
131
+ d.setHours(0, 0, 0, 0);
132
+ if(key === 'tomorrow') d.setDate(d.getDate() + 1);
133
+ if(key === 'yesterday') d.setDate(d.getDate() - 1);
134
+ return d;
135
+ }
136
+ return this.fromIso(input);
137
+ };
138
+
139
+ get minDate() {
140
+ return this.resolveDate(this.min) || this.fromIso('1900-01-01');
141
+ }
142
+
143
+ get maxDate() {
144
+ if(this.max) return this.resolveDate(this.max);
145
+ const now = new Date();
146
+ return new Date(now.getFullYear() + 10, 11, 31);
147
+ }
148
+
149
+ isDisabledDate = (date) => {
150
+ if(!date) return false;
151
+ const minDate = this.minDate;
152
+ if(minDate && date < minDate) return true;
153
+ const maxDate = this.maxDate;
154
+ if(maxDate && date > maxDate) return true;
155
+ return false;
156
+ };
157
+
158
+ get availableYears() {
159
+ const minY = this.minDate?.getFullYear() ?? 1900;
160
+ const maxY = this.maxDate?.getFullYear() ?? (new Date().getFullYear() + 10);
161
+ const years = [];
162
+ for(let y = minY; y <= maxY; y++) years.push(y);
163
+ return years;
164
+ }
165
+
166
+ isMonthDisabled = (year, month) => {
167
+ const lastDay = new Date(year, month + 1, 0);
168
+ const firstDay = new Date(year, month, 1);
169
+ const min = this.minDate;
170
+ const max = this.maxDate;
171
+ if(min && lastDay < min) return true;
172
+ if(max && firstDay > max) return true;
173
+ return false;
174
+ };
175
+
176
+ get weekGrid() {
177
+ const year = this.viewYear;
178
+ const month = this.viewMonth;
179
+ const first = new Date(year, month, 1);
180
+ const startOffset = first.getDay();
181
+ const gridStart = new Date(year, month, 1 - startOffset);
182
+ const weeks = [];
183
+ for(let w = 0; w < 6; w++){
184
+ const days = [];
185
+ for(let d = 0; d < 7; d++){
186
+ const date = new Date(gridStart);
187
+ date.setDate(gridStart.getDate() + w * 7 + d);
188
+ days.push(date);
189
+ }
190
+ weeks.push(days);
191
+ }
192
+ return weeks;
193
+ }
194
+
195
+ get monthLabel() {
196
+ return `${MONTH_NAMES[this.viewMonth]} ${this.viewYear}`;
197
+ }
198
+
199
+ /*
200
+ Navigation
201
+ */
202
+ prevMonth = () => {
203
+ const d = new Date(this.viewYear, this.viewMonth - 1, 1);
204
+ this.view = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
205
+ };
206
+
207
+ nextMonth = () => {
208
+ const d = new Date(this.viewYear, this.viewMonth + 1, 1);
209
+ this.view = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
210
+ };
211
+
212
+ goToMonth = (year, month) => {
213
+ this.view = `${year}-${String(month + 1).padStart(2, '0')}`;
214
+ };
215
+
216
+ goToToday = () => {
217
+ const today = new Date();
218
+ this.view = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`;
219
+ };
220
+
221
+ /*
222
+ Event Handlers
223
+ */
224
+ handleDayClick = (date) => {
225
+ if(this.disabled || this.isDisabledDate(date)) return;
226
+ this.#skipViewSync = true;
227
+ if(this.isRange){
228
+ const { start, end } = this.parsed;
229
+ if(!start || end){
230
+ this.value = this.toIso(date);
231
+ } else if(date < start){
232
+ this.value = `${this.toIso(date)},${this.toIso(start)}`;
233
+ } else {
234
+ this.value = `${this.toIso(start)},${this.toIso(date)}`;
235
+ }
236
+ } else {
237
+ this.value = this.toIso(date);
238
+ }
239
+ };
240
+
241
+ handleDayHover = (date) => {
242
+ if(this.isRange){
243
+ this.hoverDate = date;
244
+ }
245
+ };
246
+
247
+ handleMouseLeave = () => {
248
+ if(this.isRange){
249
+ this.hoverDate = null;
250
+ }
251
+ };
252
+
253
+ handleMonthChange = (e) => {
254
+ this.goToMonth(this.viewYear, Number(e.target.value));
255
+ };
256
+
257
+ handleYearChange = (e) => {
258
+ this.goToMonth(Number(e.target.value), this.viewMonth);
259
+ };
260
+
261
+ /*
262
+ Rendering
263
+ */
264
+ render() {
265
+ const today = new Date();
266
+ const parsed = this.parsed;
267
+ const single = parsed.date;
268
+ const rawStart = parsed.start;
269
+ const rawEnd = parsed.end;
270
+ const previewEnd = this.isRange && rawStart && !rawEnd && this.hoverDate ? this.hoverDate : rawEnd;
271
+ const rangeStart = this.isRange && rawStart && previewEnd && rawStart > previewEnd ? previewEnd : rawStart;
272
+ const rangeEnd = this.isRange && rawStart && previewEnd && rawStart > previewEnd ? rawStart : previewEnd;
273
+
274
+ return html`
275
+ <div class="header">
276
+ <select
277
+ class="month-select"
278
+ aria-label="Month"
279
+ ?disabled=${this.disabled}
280
+ @change=${this.handleMonthChange}
281
+ >
282
+ ${MONTH_NAMES.map((name, i) => html`
283
+ <option
284
+ value=${i}
285
+ ?selected=${i === this.viewMonth}
286
+ ?disabled=${this.isMonthDisabled(this.viewYear, i)}
287
+ >${name}</option>
288
+ `)}
289
+ </select>
290
+ <select
291
+ class="year-select"
292
+ aria-label="Year"
293
+ ?disabled=${this.disabled}
294
+ @change=${this.handleYearChange}
295
+ >
296
+ ${this.availableYears.map(y => html`
297
+ <option value=${y} ?selected=${y === this.viewYear}>${y}</option>
298
+ `)}
299
+ </select>
300
+ </div>
301
+ <div class="grid" @mouseleave=${this.handleMouseLeave}>
302
+ <div class="row head">
303
+ ${DAY_LABELS.map(d => html`<div class="dow">${d}</div>`)}
304
+ </div>
305
+ ${this.weekGrid.map(week => html`
306
+ <div class="row">
307
+ ${week.map(date => {
308
+ const outside = date.getMonth() !== this.viewMonth;
309
+ const isToday = this.isSameDay(date, today);
310
+ const isSelected = !this.isRange && this.isSameDay(date, single);
311
+ const isStart = this.isRange && this.isSameDay(date, rangeStart);
312
+ const isEnd = this.isRange && this.isSameDay(date, rangeEnd);
313
+ const inRange = this.isRange && this.isWithinRange(date, rangeStart, rangeEnd);
314
+ const disabled = this.isDisabledDate(date);
315
+ const isPreview = this.isRange && !rawEnd && (isStart || isEnd || inRange) && !isSelected;
316
+ const classes = [
317
+ 'day',
318
+ outside ? 'outside' : '',
319
+ isToday ? 'today' : '',
320
+ isSelected ? 'selected' : '',
321
+ isStart ? 'range-start' : '',
322
+ isEnd && !isStart ? 'range-end' : '',
323
+ inRange ? 'in-range' : '',
324
+ isPreview ? 'preview' : '',
325
+ disabled ? 'disabled' : ''
326
+ ].filter(Boolean).join(' ');
327
+ return html`
328
+ <button
329
+ type="button"
330
+ class="no-btn ${classes}"
331
+ ?disabled=${disabled || this.disabled}
332
+ aria-selected=${isSelected || isStart || isEnd ? 'true' : 'false'}
333
+ @click=${() => this.handleDayClick(date)}
334
+ @mouseenter=${() => this.handleDayHover(date)}
335
+ >${date.getDate()}</button>
336
+ `;
337
+ })}
338
+ </div>
339
+ `)}
340
+ </div>
341
+ `;
342
+ }
343
+
344
+ /*
345
+ Styles
346
+ */
347
+ static styles = css`
348
+ :host {
349
+ --day_size: 2rem;
350
+ --day_radius: var(--radius);
351
+ --day_bg__selected: var(--c_primary);
352
+ --day_tc__selected: white;
353
+ --day_bg__range: var(--c_bg__alt);
354
+ --day_bg__hover: var(--c_bg__alt);
355
+
356
+ display: inline-block;
357
+ border: 1px solid var(--c_border);
358
+ border-radius: var(--radius);
359
+ padding: 0.5rem;
360
+ background: var(--c_bg);
361
+ user-select: none;
362
+ }
363
+ :host([disabled]) {
364
+ opacity: 0.5;
365
+ pointer-events: none;
366
+ }
367
+ .header {
368
+ display: flex;
369
+ align-items: center;
370
+ gap: 0.25rem;
371
+ padding: 0.25rem;
372
+ margin-bottom: 0.25rem;
373
+ }
374
+ .month-select,
375
+ .year-select {
376
+ font-weight: 600;
377
+ width: auto;
378
+ }
379
+ .month-select {
380
+ flex: 1;
381
+ }
382
+ .grid {
383
+ display: flex;
384
+ flex-direction: column;
385
+ }
386
+ .row {
387
+ display: grid;
388
+ grid-template-columns: repeat(7, var(--day_size));
389
+ }
390
+ .row.head {
391
+ margin-bottom: 0.25rem;
392
+ }
393
+ .dow {
394
+ display: flex;
395
+ align-items: center;
396
+ justify-content: center;
397
+ font-size: 0.75rem;
398
+ color: var(--tc_muted);
399
+ font-weight: 500;
400
+ height: var(--day_size);
401
+ }
402
+ .day {
403
+ appearance: none;
404
+ background: transparent;
405
+ border: 1px solid transparent;
406
+ border-radius: var(--day_radius);
407
+ width: var(--day_size);
408
+ height: var(--day_size);
409
+ cursor: pointer;
410
+ color: var(--tc);
411
+ font: inherit;
412
+ font-size: 0.875rem;
413
+ padding: 0;
414
+ position: relative;
415
+ display: inline-flex;
416
+ align-items: center;
417
+ justify-content: center;
418
+ transition: background var(--animation_ms), color var(--animation_ms);
419
+ }
420
+ .day:hover:not(:disabled):not(.selected):not(.range-start):not(.range-end) {
421
+ background: var(--day_bg__hover);
422
+ }
423
+ .day:focus-visible {
424
+ outline: none;
425
+ border-color: var(--c_primary);
426
+ box-shadow: var(--focus_shadow);
427
+ z-index: 1;
428
+ }
429
+ .day.outside {
430
+ color: var(--tc_muted);
431
+ opacity: 0.5;
432
+ }
433
+ .day.today {
434
+ font-weight: 700;
435
+ border-color: var(--c_primary);
436
+ }
437
+ .day.selected,
438
+ .day.in-range,
439
+ .day.range-start,
440
+ .day.range-end {
441
+ background: var(--day_bg__selected);
442
+ color: var(--day_tc__selected);
443
+ border-color: transparent;
444
+ }
445
+ .day.in-range {
446
+ border-radius: 0;
447
+ }
448
+ .day.range-start:not(.range-end) {
449
+ border-top-right-radius: 0;
450
+ border-bottom-right-radius: 0;
451
+ }
452
+ .day.range-end:not(.range-start) {
453
+ border-top-left-radius: 0;
454
+ border-bottom-left-radius: 0;
455
+ }
456
+ .day.outside.in-range,
457
+ .day.outside.range-start,
458
+ .day.outside.range-end {
459
+ opacity: 0.6;
460
+ }
461
+ .day.preview.in-range,
462
+ .day.preview.range-start,
463
+ .day.preview.range-end {
464
+ opacity: 0.75;
465
+ }
466
+ .day:disabled {
467
+ opacity: 0.3;
468
+ cursor: not-allowed;
469
+ }
470
+ `;
471
+ }
472
+
473
+ customElements.define('k-calendar', Calendar);
@@ -0,0 +1,252 @@
1
+ import { html, css } from '../lit-all.min.js';
2
+ import ShadowComponent from './ShadowComponent.js';
3
+ import './Icon.js';
4
+
5
+ export default class SpeechToText extends ShadowComponent {
6
+ static properties = {
7
+ language: { type: String, reflect: true },
8
+ continuous: { type: Boolean, reflect: true },
9
+ interim: { type: Boolean, reflect: true },
10
+ minConfidence: { type: Number, reflect: true, attribute: 'min-confidence' },
11
+ timeout: { type: Number, reflect: true },
12
+ disabled: { type: Boolean, reflect: true },
13
+ listening: { type: Boolean, reflect: true }
14
+ };
15
+
16
+ #recognition = null;
17
+ #buffer = '';
18
+ #interim = '';
19
+ #unsupported = false;
20
+ #timeoutId = null;
21
+
22
+ /*
23
+ Lifecycle Callbacks
24
+ */
25
+ constructor() {
26
+ super();
27
+ this.language = 'en-US';
28
+ this.continuous = false;
29
+ this.interim = false;
30
+ this.minConfidence = 0;
31
+ this.timeout = 0;
32
+ this.disabled = false;
33
+ this.listening = false;
34
+ }
35
+
36
+ connectedCallback() {
37
+ super.connectedCallback();
38
+ const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
39
+ if(!SR){
40
+ this.#unsupported = true;
41
+ return;
42
+ }
43
+ this.#recognition = new SR();
44
+ this.#recognition.lang = this.language;
45
+ this.#recognition.continuous = this.continuous;
46
+ // Always enable interim internally so the `end` event has a fallback
47
+ // transcript when the engine doesn't finalize. The public `interim` attr
48
+ // controls whether `result` events fire for unfinalized chunks.
49
+ this.#recognition.interimResults = true;
50
+ this.#recognition.onstart = this.handleStart;
51
+ this.#recognition.onend = this.handleEnd;
52
+ this.#recognition.onresult = this.handleResult;
53
+ this.#recognition.onerror = this.handleError;
54
+ }
55
+
56
+ disconnectedCallback() {
57
+ super.disconnectedCallback();
58
+ this.#clearTimeout();
59
+ if(this.#recognition){
60
+ try { this.#recognition.stop(); } catch(e) {}
61
+ this.#recognition.onstart = null;
62
+ this.#recognition.onend = null;
63
+ this.#recognition.onresult = null;
64
+ this.#recognition.onerror = null;
65
+ this.#recognition = null;
66
+ }
67
+ }
68
+
69
+ updated(changedProperties) {
70
+ super.updated(changedProperties);
71
+ if(this.#recognition){
72
+ if(changedProperties.has('language')) this.#recognition.lang = this.language;
73
+ if(changedProperties.has('continuous')) this.#recognition.continuous = this.continuous;
74
+ }
75
+ }
76
+
77
+ /*
78
+ Event Handlers
79
+ */
80
+ handleStart = () => {
81
+ this.#buffer = '';
82
+ this.#interim = '';
83
+ this.listening = true;
84
+ this.#armTimeout();
85
+ this.dispatchEvent(new CustomEvent('start', { bubbles: true }));
86
+ };
87
+
88
+ handleResult = (e) => {
89
+ let final = '';
90
+ let interim = '';
91
+ for(let i = e.resultIndex; i < e.results.length; i++){
92
+ const res = e.results[i];
93
+ const alt = res[0];
94
+ const confidence = typeof alt.confidence === 'number' ? alt.confidence : 0;
95
+ if(confidence < this.minConfidence) continue;
96
+ if(res.isFinal) final += alt.transcript;
97
+ else interim += alt.transcript;
98
+ }
99
+ if(final) this.#buffer += final;
100
+ // Only overwrite the cached interim when this event actually carries text.
101
+ // Chrome occasionally fires a "settling" result event where final/interim
102
+ // are both empty — wiping #interim in that case loses the last transcript
103
+ // and the `end` event ends up with nothing to report.
104
+ if(final || interim){
105
+ this.#interim = interim;
106
+ }
107
+ if(final || this.interim){
108
+ this.dispatchEvent(new CustomEvent('result', {
109
+ detail: {
110
+ text: (this.#buffer + (interim || this.#interim)).trim(),
111
+ isFinal: !!final && !interim
112
+ },
113
+ bubbles: true
114
+ }));
115
+ }
116
+ };
117
+
118
+ handleEnd = () => {
119
+ this.listening = false;
120
+ this.#clearTimeout();
121
+ // Include any unfinalized interim text — Chrome occasionally fires `end`
122
+ // without finalizing the last utterance.
123
+ const text = (this.#buffer + this.#interim).trim();
124
+ this.dispatchEvent(new CustomEvent('end', {
125
+ detail: { text },
126
+ bubbles: true
127
+ }));
128
+ };
129
+
130
+ handleError = (e) => {
131
+ this.listening = false;
132
+ this.#clearTimeout();
133
+ this.dispatchEvent(new CustomEvent('error', {
134
+ detail: { error: e.error, message: e.message },
135
+ bubbles: true
136
+ }));
137
+ };
138
+
139
+ #armTimeout = () => {
140
+ this.#clearTimeout();
141
+ if(this.timeout > 0){
142
+ this.#timeoutId = setTimeout(() => this.stop(), this.timeout * 1000);
143
+ }
144
+ };
145
+
146
+ #clearTimeout = () => {
147
+ if(this.#timeoutId){
148
+ clearTimeout(this.#timeoutId);
149
+ this.#timeoutId = null;
150
+ }
151
+ };
152
+
153
+ handleClick = () => {
154
+ if(this.disabled) return;
155
+ if(this.listening) this.stop();
156
+ else this.start();
157
+ };
158
+
159
+ /*
160
+ Public Methods
161
+ */
162
+ start = () => {
163
+ if(this.disabled || !this.#recognition || this.listening) return;
164
+ try { this.#recognition.start(); } catch(e) {}
165
+ };
166
+
167
+ stop = () => {
168
+ if(!this.#recognition || !this.listening) return;
169
+ try { this.#recognition.stop(); } catch(e) {}
170
+ };
171
+
172
+ toggle = () => {
173
+ this.handleClick();
174
+ };
175
+
176
+ /*
177
+ Rendering
178
+ */
179
+ render() {
180
+ const supported = !this.#unsupported;
181
+ const label = !supported
182
+ ? 'Speech recognition not supported in this browser'
183
+ : this.listening ? 'Stop listening' : 'Start listening';
184
+ return html`
185
+ <button
186
+ type="button"
187
+ class="no-btn btn ${this.listening ? 'listening' : ''}"
188
+ ?disabled=${this.disabled || !supported}
189
+ @click=${this.handleClick}
190
+ aria-label=${label}
191
+ title=${label}
192
+ >
193
+ <k-icon name=${this.listening ? 'stop' : 'mic'}></k-icon>
194
+ </button>
195
+ `;
196
+ }
197
+
198
+ /*
199
+ Styles
200
+ */
201
+ static styles = css`
202
+ :host {
203
+ --btn_size: 2.5rem;
204
+ --btn_bg: var(--c_bg);
205
+ --btn_bg__listening: var(--c_danger, #d32f2f);
206
+ --btn_tc__listening: white;
207
+ display: inline-block;
208
+ }
209
+ :host([disabled]) {
210
+ opacity: 0.5;
211
+ pointer-events: none;
212
+ }
213
+ .btn {
214
+ display: inline-flex;
215
+ align-items: center;
216
+ justify-content: center;
217
+ width: var(--btn_size);
218
+ height: var(--btn_size);
219
+ padding: 0;
220
+ border: 1px solid var(--c_border);
221
+ border-radius: 50%;
222
+ background: var(--btn_bg);
223
+ color: var(--tc);
224
+ cursor: pointer;
225
+ transition: background var(--animation_ms), color var(--animation_ms), border-color var(--animation_ms);
226
+ }
227
+ .btn:hover:not(:disabled) {
228
+ background: var(--c_bg__alt);
229
+ }
230
+ .btn:focus-visible {
231
+ outline: none;
232
+ box-shadow: var(--focus_shadow);
233
+ }
234
+ :host([listening]) .btn,
235
+ :host([listening]) .btn:hover,
236
+ :host([listening]) .btn:focus-visible {
237
+ background: var(--btn_bg__listening);
238
+ color: var(--btn_tc__listening);
239
+ border-color: transparent;
240
+ animation: pulse 1.4s infinite;
241
+ }
242
+ .btn:disabled {
243
+ cursor: not-allowed;
244
+ }
245
+ @keyframes pulse {
246
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(211, 47, 47, 0.5); }
247
+ 50% { box-shadow: 0 0 0 10px rgba(211, 47, 47, 0); }
248
+ }
249
+ `;
250
+ }
251
+
252
+ customElements.define('k-speech-to-text', SpeechToText);