kempo-ui 0.4.4 → 0.4.6

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 (185) hide show
  1. package/dist/components/Chat.js +201 -0
  2. package/dist/components/CodeEditor.js +21 -2
  3. package/dist/components/Dropdown.js +1 -1
  4. package/dist/components/HtmlEditor.js +42 -3
  5. package/dist/components/MarkdownEditor.js +173 -0
  6. package/dist/components/Resize.js +12 -2
  7. package/dist/components/TextToSpeech.js +4 -4
  8. package/dist/components/VoiceSelector.js +49 -0
  9. package/dist/components/markdownEditorControls/Bold.js +11 -0
  10. package/dist/components/markdownEditorControls/BulletList.js +11 -0
  11. package/dist/components/markdownEditorControls/Code.js +11 -0
  12. package/dist/components/markdownEditorControls/FormatBlock.js +30 -0
  13. package/dist/components/markdownEditorControls/Heading.js +11 -0
  14. package/dist/components/markdownEditorControls/Image.js +93 -0
  15. package/dist/components/markdownEditorControls/Italic.js +11 -0
  16. package/dist/components/markdownEditorControls/Link.js +11 -0
  17. package/dist/components/markdownEditorControls/MarkdownEditorControl.js +39 -0
  18. package/dist/components/markdownEditorControls/Menu.js +30 -0
  19. package/dist/components/markdownEditorControls/NumberedList.js +11 -0
  20. package/dist/components/markdownEditorControls/Quote.js +11 -0
  21. package/dist/components/markdownEditorControls/SpeechToText.js +16 -0
  22. package/dist/components/markdownEditorControls/Strikethrough.js +11 -0
  23. package/dist/components/markdownEditorControls/Table.js +80 -0
  24. package/dist/utils/marked.esm.js +1 -0
  25. package/dist/utils/renderMarkdown.js +1 -0
  26. package/dist/utils/sanitizeHtml.js +1 -0
  27. package/dist/utils/voice.js +1 -0
  28. package/docs/components/accordion.html +19 -0
  29. package/docs/components/aside.html +19 -0
  30. package/docs/components/calendar.html +19 -0
  31. package/docs/components/card.html +19 -0
  32. package/docs/components/chat.html +715 -0
  33. package/docs/components/code-editor.html +55 -0
  34. package/docs/components/color-picker.html +19 -0
  35. package/docs/components/combobox.html +19 -0
  36. package/docs/components/content-slider.html +19 -0
  37. package/docs/components/context.html +19 -0
  38. package/docs/components/dialog.html +19 -0
  39. package/docs/components/dropdown.html +19 -0
  40. package/docs/components/filter-list.html +19 -0
  41. package/docs/components/focus-capture.html +19 -0
  42. package/docs/components/html-editor.html +60 -0
  43. package/docs/components/hybrid-component.html +19 -0
  44. package/docs/components/icon.html +19 -0
  45. package/docs/components/import.html +19 -0
  46. package/docs/components/light-component.html +19 -0
  47. package/docs/components/markdown-editor.html +924 -0
  48. package/docs/components/nav-spacer.html +19 -0
  49. package/docs/components/nav.html +19 -0
  50. package/docs/components/photo-viewer.html +19 -0
  51. package/docs/components/progress.html +19 -0
  52. package/docs/components/resize.html +22 -0
  53. package/docs/components/rich-textarea.html +650 -0
  54. package/docs/components/shadow-component.html +19 -0
  55. package/docs/components/show-more.html +19 -0
  56. package/docs/components/slider.html +19 -0
  57. package/docs/components/sortable.html +19 -0
  58. package/docs/components/speech-to-text.html +19 -0
  59. package/docs/components/spinner.html +19 -0
  60. package/docs/components/split.html +19 -0
  61. package/docs/components/table.html +19 -0
  62. package/docs/components/tableControls.html +19 -0
  63. package/docs/components/tableCustomFields.html +19 -0
  64. package/docs/components/tableFetchRecords.html +19 -0
  65. package/docs/components/tableFieldSortHide.html +19 -0
  66. package/docs/components/tablePagination.html +19 -0
  67. package/docs/components/tablePlaceholder.html +19 -0
  68. package/docs/components/tableRecordEditing.html +19 -0
  69. package/docs/components/tableRecordFiltering.html +19 -0
  70. package/docs/components/tableRecordHiding.html +19 -0
  71. package/docs/components/tableRecordSearching.html +19 -0
  72. package/docs/components/tableRecordSelection.html +19 -0
  73. package/docs/components/tableRowControls.html +19 -0
  74. package/docs/components/tableServerSync.html +19 -0
  75. package/docs/components/tableSorting.html +19 -0
  76. package/docs/components/tabs.html +19 -0
  77. package/docs/components/tags.html +19 -0
  78. package/docs/components/text-to-speech.html +63 -1
  79. package/docs/components/theme-select.html +19 -0
  80. package/docs/components/theme-switcher.html +19 -0
  81. package/docs/components/time.html +19 -0
  82. package/docs/components/timestamp.html +19 -0
  83. package/docs/components/toast.html +19 -0
  84. package/docs/components/toggle.html +19 -0
  85. package/docs/components/tree.html +19 -0
  86. package/docs/components/voice-selector.html +610 -0
  87. package/docs/icons/done_all.svg +1 -0
  88. package/docs/icons/format_quote.svg +1 -0
  89. package/docs/icons/format_strikethrough.svg +1 -0
  90. package/docs/icons/send.svg +1 -0
  91. package/docs/icons/text_fields.svg +1 -0
  92. package/docs/index.html +49 -0
  93. package/docs/src/components/Chat.js +201 -0
  94. package/docs/src/components/CodeEditor.js +21 -2
  95. package/docs/src/components/Dropdown.js +1 -1
  96. package/docs/src/components/HtmlEditor.js +42 -3
  97. package/docs/src/components/MarkdownEditor.js +173 -0
  98. package/docs/src/components/Resize.js +12 -2
  99. package/docs/src/components/TextToSpeech.js +4 -4
  100. package/docs/src/components/VoiceSelector.js +49 -0
  101. package/docs/src/components/markdownEditorControls/Bold.js +11 -0
  102. package/docs/src/components/markdownEditorControls/BulletList.js +11 -0
  103. package/docs/src/components/markdownEditorControls/Code.js +11 -0
  104. package/docs/src/components/markdownEditorControls/FormatBlock.js +30 -0
  105. package/docs/src/components/markdownEditorControls/Heading.js +11 -0
  106. package/docs/src/components/markdownEditorControls/Image.js +93 -0
  107. package/docs/src/components/markdownEditorControls/Italic.js +11 -0
  108. package/docs/src/components/markdownEditorControls/Link.js +11 -0
  109. package/docs/src/components/markdownEditorControls/MarkdownEditorControl.js +39 -0
  110. package/docs/src/components/markdownEditorControls/Menu.js +30 -0
  111. package/docs/src/components/markdownEditorControls/NumberedList.js +11 -0
  112. package/docs/src/components/markdownEditorControls/Quote.js +11 -0
  113. package/docs/src/components/markdownEditorControls/SpeechToText.js +16 -0
  114. package/docs/src/components/markdownEditorControls/Strikethrough.js +11 -0
  115. package/docs/src/components/markdownEditorControls/Table.js +80 -0
  116. package/docs/src/utils/marked.esm.js +1 -0
  117. package/docs/src/utils/renderMarkdown.js +1 -0
  118. package/docs/src/utils/sanitizeHtml.js +1 -0
  119. package/docs/src/utils/voice.js +1 -0
  120. package/docs/utils/context.html +19 -0
  121. package/docs/utils/cookie.html +19 -0
  122. package/docs/utils/debounce.html +19 -0
  123. package/docs/utils/drag.html +19 -0
  124. package/docs/utils/elevation.html +23 -10
  125. package/docs/utils/formatTimestamp.html +19 -0
  126. package/docs/utils/object.html +19 -0
  127. package/docs/utils/propConverters.html +19 -0
  128. package/docs/utils/sanitizeHtml.html +555 -0
  129. package/docs/utils/string.html +19 -0
  130. package/docs/utils/theme.html +19 -0
  131. package/docs/utils/toTitleCase.html +19 -0
  132. package/docs/utils/type.html +19 -0
  133. package/docs/utils/voice.html +548 -0
  134. package/docs/utils/wait.html +19 -0
  135. package/docs-src/components/chat.page.html +230 -0
  136. package/docs-src/components/code-editor.page.html +36 -0
  137. package/docs-src/components/html-editor.page.html +41 -0
  138. package/docs-src/components/markdown-editor.page.html +439 -0
  139. package/docs-src/components/resize.page.html +3 -0
  140. package/docs-src/components/text-to-speech.page.html +44 -1
  141. package/docs-src/components/voice-selector.page.html +125 -0
  142. package/docs-src/index.page.html +30 -0
  143. package/docs-src/nav.fragment.html +19 -0
  144. package/docs-src/utils/elevation.page.html +4 -10
  145. package/docs-src/utils/sanitizeHtml.page.html +73 -0
  146. package/docs-src/utils/voice.page.html +66 -0
  147. package/icons/done_all.svg +1 -0
  148. package/icons/format_quote.svg +1 -0
  149. package/icons/format_strikethrough.svg +1 -0
  150. package/icons/send.svg +1 -0
  151. package/icons/text_fields.svg +1 -0
  152. package/llms.txt +7 -2
  153. package/package.json +3 -2
  154. package/src/components/Chat.js +435 -0
  155. package/src/components/CodeEditor.js +58 -2
  156. package/src/components/Dropdown.js +1 -1
  157. package/src/components/HtmlEditor.js +84 -4
  158. package/src/components/MarkdownEditor.js +654 -0
  159. package/src/components/Resize.js +17 -1
  160. package/src/components/TextToSpeech.js +13 -2
  161. package/src/components/VoiceSelector.js +271 -0
  162. package/src/components/markdownEditorControls/Bold.js +30 -0
  163. package/src/components/markdownEditorControls/BulletList.js +30 -0
  164. package/src/components/markdownEditorControls/Code.js +39 -0
  165. package/src/components/markdownEditorControls/FormatBlock.js +102 -0
  166. package/src/components/markdownEditorControls/Heading.js +45 -0
  167. package/src/components/markdownEditorControls/Image.js +250 -0
  168. package/src/components/markdownEditorControls/Italic.js +30 -0
  169. package/src/components/markdownEditorControls/Link.js +74 -0
  170. package/src/components/markdownEditorControls/MarkdownEditorControl.js +158 -0
  171. package/src/components/markdownEditorControls/Menu.js +86 -0
  172. package/src/components/markdownEditorControls/NumberedList.js +69 -0
  173. package/src/components/markdownEditorControls/Quote.js +30 -0
  174. package/src/components/markdownEditorControls/SpeechToText.js +65 -0
  175. package/src/components/markdownEditorControls/Strikethrough.js +35 -0
  176. package/src/components/markdownEditorControls/Table.js +164 -0
  177. package/src/utils/marked.esm.js +77 -0
  178. package/src/utils/renderMarkdown.js +29 -0
  179. package/src/utils/sanitizeHtml.js +137 -0
  180. package/src/utils/voice.js +103 -0
  181. package/tests/components/Chat.browser-test.js +540 -0
  182. package/tests/components/TextToSpeech.browser-test.js +150 -1
  183. package/tests/components/VoiceSelector.browser-test.js +374 -0
  184. package/tests/utils/sanitizeHtml.browser-test.js +179 -0
  185. package/tests/utils/voice.browser-test.js +128 -0
@@ -0,0 +1,435 @@
1
+ import { html, css, nothing } from '../lit-all.min.js';
2
+ import ShadowComponent from './ShadowComponent.js';
3
+ import sanitizeHtml from '../utils/sanitizeHtml.js';
4
+ import renderMarkdown from '../utils/renderMarkdown.js';
5
+ import './MarkdownEditor.js';
6
+ import './markdownEditorControls/Bold.js';
7
+ import './markdownEditorControls/Italic.js';
8
+ import './markdownEditorControls/Code.js';
9
+ import './markdownEditorControls/Link.js';
10
+ import './markdownEditorControls/BulletList.js';
11
+ import './markdownEditorControls/NumberedList.js';
12
+ import './markdownEditorControls/Quote.js';
13
+ import './Spinner.js';
14
+ import './Icon.js';
15
+
16
+ const newId = () => {
17
+ if(typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'){
18
+ return crypto.randomUUID();
19
+ }
20
+ return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
21
+ };
22
+
23
+ const STATUSES = new Set(['sending', 'delivered', 'read', 'failed']);
24
+
25
+ export default class Chat extends ShadowComponent {
26
+ static properties = {
27
+ enterNewline: { type: Boolean, reflect: true, attribute: 'enter-newline' },
28
+ // show-status is a string attr: absent = off, "" or "text" = text labels,
29
+ // "icons" = icon-only badges. <k-chat show-status> defaults to text mode.
30
+ showStatus: { type: String, reflect: true, attribute: 'show-status' },
31
+ placeholder: { type: String, reflect: true },
32
+ disabled: { type: Boolean, reflect: true },
33
+ messages: { state: true }
34
+ };
35
+
36
+ /*
37
+ Lifecycle Callbacks
38
+ */
39
+ constructor() {
40
+ super();
41
+ // Default matches Slack/Discord/Messages: Enter sends, Shift+Enter newline.
42
+ // Add the `enter-newline` attribute to invert (Enter inserts a newline,
43
+ // Shift+Enter or the Send button submits).
44
+ this.enterNewline = false;
45
+ this.showStatus = null;
46
+ this.placeholder = 'Type a message...';
47
+ this.disabled = false;
48
+ this.messages = [];
49
+ }
50
+
51
+ updated(changedProperties) {
52
+ super.updated(changedProperties);
53
+ if(changedProperties.has('messages')){
54
+ this.#scrollToBottom();
55
+ }
56
+ }
57
+
58
+ /*
59
+ Public Methods
60
+ */
61
+ addMessage(msg = {}) {
62
+ const type = msg.type === 'outgoing' ? 'outgoing' : 'incoming';
63
+ const id = msg.id || newId();
64
+ const html = sanitizeHtml(msg.html || '');
65
+ const status = STATUSES.has(msg.status)
66
+ ? msg.status
67
+ : (type === 'outgoing' ? 'delivered' : 'read');
68
+ const message = {
69
+ id,
70
+ type,
71
+ html,
72
+ status,
73
+ sender: msg.sender || '',
74
+ timestamp: msg.timestamp instanceof Date ? msg.timestamp : new Date()
75
+ };
76
+ this.messages = [...this.messages, message];
77
+ return id;
78
+ }
79
+
80
+ updateMessage(id, updates = {}) {
81
+ let changed = false;
82
+ this.messages = this.messages.map(m => {
83
+ if(m.id !== id) return m;
84
+ changed = true;
85
+ const next = { ...m };
86
+ if(updates.html !== undefined) next.html = sanitizeHtml(updates.html);
87
+ if(updates.status !== undefined && STATUSES.has(updates.status)) next.status = updates.status;
88
+ if(updates.sender !== undefined) next.sender = updates.sender;
89
+ if(updates.timestamp instanceof Date) next.timestamp = updates.timestamp;
90
+ return next;
91
+ });
92
+ return changed;
93
+ }
94
+
95
+ removeMessage(id) {
96
+ const before = this.messages.length;
97
+ this.messages = this.messages.filter(m => m.id !== id);
98
+ return this.messages.length !== before;
99
+ }
100
+
101
+ clear() {
102
+ this.messages = [];
103
+ }
104
+
105
+ send() {
106
+ if(this.disabled) return null;
107
+ const editor = this.shadowRoot?.querySelector('k-markdown-editor');
108
+ if(!editor) return null;
109
+ const markdown = (editor.value || '').trim();
110
+ if(!markdown) return null;
111
+ // breaks:true makes single newlines render as <br>, matching Slack /
112
+ // iMessage convention where each typed Enter is a visible break.
113
+ const safe = sanitizeHtml(renderMarkdown(markdown, { breaks: true }));
114
+ const id = this.addMessage({
115
+ type: 'outgoing',
116
+ html: safe,
117
+ status: this.#statusEnabled ? 'sending' : 'delivered'
118
+ });
119
+ editor.clear();
120
+ this.dispatchEvent(new CustomEvent('send', {
121
+ detail: { id, html: safe, markdown },
122
+ bubbles: true
123
+ }));
124
+ return id;
125
+ }
126
+
127
+ /*
128
+ Event Handlers
129
+ */
130
+ handleSendClick = () => this.send();
131
+
132
+ handleEditorKeydown = (e) => {
133
+ if(this.disabled) return;
134
+ if(e.key !== 'Enter' || e.ctrlKey || e.metaKey || e.altKey) return;
135
+ // Default (no enter-newline attr): Enter sends, Shift+Enter inserts a newline.
136
+ // With enter-newline attr: Enter inserts a newline, Shift+Enter sends.
137
+ const shouldSend = this.enterNewline ? e.shiftKey : !e.shiftKey;
138
+ if(!shouldSend) return;
139
+ e.preventDefault();
140
+ this.send();
141
+ };
142
+
143
+ /*
144
+ Utility
145
+ */
146
+ #scrollToBottom = () => {
147
+ this.updateComplete.then(() => {
148
+ const window = this.shadowRoot?.querySelector('.window');
149
+ if(window) window.scrollTop = window.scrollHeight;
150
+ });
151
+ };
152
+
153
+ /*
154
+ Rendering
155
+ */
156
+ get #lastOutgoingId() {
157
+ for(let i = this.messages.length - 1; i >= 0; i--){
158
+ if(this.messages[i].type === 'outgoing') return this.messages[i].id;
159
+ }
160
+ return null;
161
+ }
162
+
163
+ get #statusEnabled() {
164
+ return this.showStatus != null && this.showStatus !== 'false';
165
+ }
166
+
167
+ get #statusMode() {
168
+ return this.showStatus === 'icons' ? 'icons' : 'text';
169
+ }
170
+
171
+ // iMessage convention: per-message status only appears on the most recent
172
+ // outgoing message — older delivered/read messages don't carry an
173
+ // indicator. Sending or failed always show, regardless of position, so
174
+ // the user knows about in-flight or failed messages immediately.
175
+ #shouldShowStatus(message, lastOutgoingId) {
176
+ if(!this.#statusEnabled) return false;
177
+ if(message.type !== 'outgoing') return false;
178
+ if(message.status === 'sending' || message.status === 'failed') return true;
179
+ return message.id === lastOutgoingId;
180
+ }
181
+
182
+ #renderStatus(status) {
183
+ if(this.#statusMode === 'icons') return this.#renderStatusIcon(status);
184
+ return this.#renderStatusText(status);
185
+ }
186
+
187
+ #renderStatusText(status) {
188
+ if(status === 'sending'){
189
+ return html`<span class="status sending"><k-spinner size="xs"></k-spinner> Sending&hellip;</span>`;
190
+ }
191
+ if(status === 'delivered'){
192
+ return html`<span class="status">Delivered</span>`;
193
+ }
194
+ if(status === 'read'){
195
+ return html`<span class="status">Read</span>`;
196
+ }
197
+ if(status === 'failed'){
198
+ return html`<span class="status failed"><k-icon name="error"></k-icon> Not Delivered</span>`;
199
+ }
200
+ return nothing;
201
+ }
202
+
203
+ #renderStatusIcon(status) {
204
+ if(status === 'sending'){
205
+ return html`<span class="status status-icon-only"><k-spinner size="xs" title="Sending"></k-spinner></span>`;
206
+ }
207
+ if(status === 'delivered'){
208
+ return html`<span class="status status-icon-only"><k-icon name="check" title="Delivered"></k-icon></span>`;
209
+ }
210
+ if(status === 'read'){
211
+ return html`<span class="status status-icon-only"><k-icon name="done_all" title="Read"></k-icon></span>`;
212
+ }
213
+ if(status === 'failed'){
214
+ return html`<span class="status status-icon-only failed"><k-icon name="error" title="Not Delivered"></k-icon></span>`;
215
+ }
216
+ return nothing;
217
+ }
218
+
219
+ #renderMessage(message, lastOutgoingId) {
220
+ const showStatus = this.#shouldShowStatus(message, lastOutgoingId);
221
+ return html`
222
+ <div
223
+ class="message ${message.type} ${message.status}"
224
+ data-id=${message.id}
225
+ >
226
+ ${message.sender ? html`<div class="sender">${message.sender}</div>` : nothing}
227
+ <div class="bubble">
228
+ <div class="content" .innerHTML=${message.html}></div>
229
+ </div>
230
+ ${showStatus ? this.#renderStatus(message.status) : nothing}
231
+ </div>
232
+ `;
233
+ }
234
+
235
+ render() {
236
+ const lastOutgoingId = this.#lastOutgoingId;
237
+ return html`
238
+ <div class="window" role="log" aria-live="polite">
239
+ ${this.messages.map(m => this.#renderMessage(m, lastOutgoingId))}
240
+ </div>
241
+ <div class="input-area">
242
+ <k-markdown-editor
243
+ class="editor"
244
+ placeholder=${this.placeholder}
245
+ ?disabled=${this.disabled}
246
+ @keydown=${this.handleEditorKeydown}
247
+ >
248
+ <div slot="controls-top" class="controls-top">
249
+ <slot name="controls-top">
250
+ <k-md-bold></k-md-bold>
251
+ <k-md-italic></k-md-italic>
252
+ <k-md-code></k-md-code>
253
+ <k-md-link></k-md-link>
254
+ <k-md-bullet-list></k-md-bullet-list>
255
+ <k-md-numbered-list></k-md-numbered-list>
256
+ <k-md-quote></k-md-quote>
257
+ </slot>
258
+ </div>
259
+ <div slot="controls-bottom" class="controls-bottom">
260
+ <slot name="controls-bottom"></slot>
261
+ <button
262
+ type="button"
263
+ class="send-btn primary"
264
+ ?disabled=${this.disabled}
265
+ @click=${this.handleSendClick}
266
+ aria-label="Send message"
267
+ title="Send message"
268
+ >
269
+ <k-icon name="send"></k-icon>
270
+ </button>
271
+ </div>
272
+ </k-markdown-editor>
273
+ </div>
274
+ `;
275
+ }
276
+
277
+ /*
278
+ Styles
279
+ */
280
+ static styles = css`
281
+ :host {
282
+ --window_min_height: 16rem;
283
+ --window_max_height: 32rem;
284
+ --chat_padding: 0.5rem;
285
+ --chat_gap: 0.5rem;
286
+ --message_radius: 1rem;
287
+ --message_max_width: 75%;
288
+ --bubble_padding: 0.5rem 0.75rem;
289
+ --bubble_bg__incoming: var(--c_bg__alt);
290
+ --bubble_bg__outgoing: var(--c_primary);
291
+ --bubble_tc__outgoing: white;
292
+ --send_btn_bg: var(--c_primary);
293
+ --send_btn_tc: white;
294
+
295
+ display: flex;
296
+ flex-direction: column;
297
+ border: 1px solid var(--c_border);
298
+ border-radius: var(--radius);
299
+ background: var(--c_bg);
300
+ /* Note: NO overflow:hidden on the host — that would clip the input
301
+ area's focus shadow. Each child clips its own content against the
302
+ shared rounded corners instead. */
303
+ }
304
+ :host([disabled]) {
305
+ opacity: 0.6;
306
+ }
307
+ .window {
308
+ flex: 1 1 auto;
309
+ min-height: var(--window_min_height);
310
+ max-height: var(--window_max_height);
311
+ overflow-y: auto;
312
+ padding: var(--chat_padding);
313
+ display: flex;
314
+ flex-direction: column;
315
+ gap: var(--chat_gap);
316
+ scroll-behavior: smooth;
317
+ /* Match the chat's outer rounded corners on the top edge so messages
318
+ don't render past them. */
319
+ border-top-left-radius: var(--radius);
320
+ border-top-right-radius: var(--radius);
321
+ }
322
+ .message {
323
+ display: flex;
324
+ flex-direction: column;
325
+ max-width: var(--message_max_width);
326
+ gap: 0.125rem;
327
+ }
328
+ .message.outgoing {
329
+ align-self: flex-end;
330
+ align-items: flex-end;
331
+ }
332
+ .message.incoming {
333
+ align-self: flex-start;
334
+ align-items: flex-start;
335
+ }
336
+ .sender {
337
+ font-size: 0.75rem;
338
+ color: var(--tc_muted);
339
+ }
340
+ .bubble {
341
+ padding: var(--bubble_padding);
342
+ border-radius: var(--message_radius);
343
+ background: var(--bubble_bg__incoming);
344
+ color: var(--tc);
345
+ word-wrap: break-word;
346
+ overflow-wrap: anywhere;
347
+ }
348
+ .message.outgoing .bubble {
349
+ background: var(--bubble_bg__outgoing);
350
+ color: var(--bubble_tc__outgoing);
351
+ }
352
+ .message.outgoing.sending .bubble {
353
+ opacity: 0.7;
354
+ }
355
+ .message.outgoing.failed .bubble {
356
+ background: var(--c_danger, #d32f2f);
357
+ }
358
+ .content {
359
+ line-height: 1.4;
360
+ }
361
+ .content p:first-child { margin-top: 0; }
362
+ .content p:last-child { margin-bottom: 0; }
363
+ /* iMessage-style small grey label below the message bubble. */
364
+ .status {
365
+ display: inline-flex;
366
+ align-items: center;
367
+ gap: 0.25rem;
368
+ font-size: 0.7rem;
369
+ color: var(--tc_muted);
370
+ margin-top: 0.125rem;
371
+ }
372
+ .status.failed {
373
+ color: var(--c_danger, #d32f2f);
374
+ }
375
+ .status k-spinner,
376
+ .status k-icon {
377
+ font-size: 0.8rem;
378
+ }
379
+ .status-icon-only {
380
+ font-size: 0.95rem;
381
+ }
382
+ .input-area {
383
+ position: relative;
384
+ border-top: 1px solid var(--c_border);
385
+ border-bottom-left-radius: var(--radius);
386
+ border-bottom-right-radius: var(--radius);
387
+ }
388
+ .editor {
389
+ /* Markdown editor fills the bottom of the chat. CSS custom properties
390
+ pierce shadow boundaries -- '--height' sets the initial size of the
391
+ editor's internal Resize wrapper. */
392
+ --height: 13rem;
393
+ display: block;
394
+ width: 100%;
395
+ }
396
+ .controls-top,
397
+ .controls-bottom {
398
+ display: flex;
399
+ align-items: center;
400
+ flex-wrap: wrap;
401
+ gap: 0.125rem;
402
+ }
403
+ .controls-bottom {
404
+ width: 100%;
405
+ }
406
+ .send-btn {
407
+ margin-left: auto;
408
+ display: inline-flex;
409
+ align-items: center;
410
+ justify-content: center;
411
+ width: 2rem;
412
+ height: 2rem;
413
+ padding: 0;
414
+ border: 1px solid transparent;
415
+ border-radius: 50%;
416
+ background: var(--c_primary);
417
+ color: white;
418
+ cursor: pointer;
419
+ transition: opacity var(--animation_ms);
420
+ }
421
+ .send-btn:hover:not(:disabled) {
422
+ opacity: 0.9;
423
+ }
424
+ .send-btn:focus-visible {
425
+ outline: none;
426
+ box-shadow: var(--focus_shadow);
427
+ }
428
+ .send-btn:disabled {
429
+ cursor: not-allowed;
430
+ opacity: 0.5;
431
+ }
432
+ `;
433
+ }
434
+
435
+ customElements.define('k-chat', Chat);
@@ -23,7 +23,10 @@ export default class CodeEditor extends ShadowComponent {
23
23
  hasBottomToolbar: { type: Boolean, state: true },
24
24
  fullscreen: { type: Boolean, reflect: true },
25
25
  wordWrap: { type: Boolean },
26
- minimapEnabled: { type: Boolean }
26
+ minimapEnabled: { type: Boolean },
27
+ disabled: { type: Boolean, reflect: true },
28
+ readonly: { type: Boolean, reflect: true },
29
+ required: { type: Boolean, reflect: true }
27
30
  };
28
31
 
29
32
  constructor() {
@@ -44,6 +47,9 @@ export default class CodeEditor extends ShadowComponent {
44
47
  this.minimapEnabled = false;
45
48
  this.fontSize = 14;
46
49
  this.fullscreen = false;
50
+ this.disabled = false;
51
+ this.readonly = false;
52
+ this.required = false;
47
53
  }
48
54
 
49
55
  /*
@@ -115,6 +121,32 @@ export default class CodeEditor extends ShadowComponent {
115
121
  if(!this.fullscreen) this.monacoEditor?.layout({ width: 0, height: 0 });
116
122
  requestAnimationFrame(() => this.monacoEditor?.layout());
117
123
  }
124
+ if(changedProperties.has('disabled') || changedProperties.has('readonly')){
125
+ this.monacoEditor?.updateOptions({ readOnly: this.disabled || this.readonly });
126
+ }
127
+ if(
128
+ changedProperties.has('value') ||
129
+ changedProperties.has('required') ||
130
+ changedProperties.has('disabled')
131
+ ){
132
+ this.#updateValidity();
133
+ }
134
+ }
135
+
136
+ #updateValidity = () => {
137
+ if(this.disabled){
138
+ this.internals.setValidity({});
139
+ return;
140
+ }
141
+ if(this.required && !(this.getValue() || '').trim()){
142
+ this.internals.setValidity(
143
+ { valueMissing: true },
144
+ 'Please fill out this field.',
145
+ this.monacoContainer || this
146
+ );
147
+ } else {
148
+ this.internals.setValidity({});
149
+ }
118
150
  }
119
151
 
120
152
  async firstUpdated() {
@@ -193,7 +225,8 @@ export default class CodeEditor extends ShadowComponent {
193
225
  scrollBeyondLastLine: false,
194
226
  automaticLayout: true,
195
227
  tabSize: 2,
196
- padding: { top: 8 }
228
+ padding: { top: 8 },
229
+ readOnly: this.disabled || this.readonly
197
230
  });
198
231
 
199
232
  const monacoCSS = document.querySelector('link[href*="monaco"][href*="editor.main.css"]');
@@ -235,6 +268,10 @@ export default class CodeEditor extends ShadowComponent {
235
268
  this.value = state;
236
269
  }
237
270
 
271
+ formDisabledCallback(disabled) {
272
+ this.disabled = disabled;
273
+ }
274
+
238
275
  /*
239
276
  Public Methods
240
277
  */
@@ -460,6 +497,25 @@ export default class CodeEditor extends ShadowComponent {
460
497
  [hidden] {
461
498
  display: none !important;
462
499
  }
500
+ :host([disabled]) {
501
+ opacity: 0.6;
502
+ }
503
+ /* disabled blocks all interaction -- toolbar AND editor. Monaco's
504
+ readOnly already prevents typing; pointer-events: none also stops
505
+ focus / cursor placement, matching native form control semantics. */
506
+ :host([disabled]) .toolbar-top,
507
+ :host([disabled]) .toolbar-bottom,
508
+ :host([disabled]) .editor-container {
509
+ pointer-events: none;
510
+ }
511
+ /* readonly keeps the editor interactive (so users can place a cursor
512
+ to select / copy) but mutes the toolbar so its buttons can't
513
+ mutate the document. */
514
+ :host([readonly]) .toolbar-top,
515
+ :host([readonly]) .toolbar-bottom {
516
+ pointer-events: none;
517
+ opacity: 0.5;
518
+ }
463
519
  `;
464
520
 
465
521
  render() {
@@ -257,7 +257,7 @@ export default class Dropdown extends ShadowComponent {
257
257
  display: none;
258
258
  position: fixed;
259
259
  position-anchor: --dropdown-trigger;
260
- z-index: 70;
260
+ z-index: 30;
261
261
  min-width: anchor-size(width);
262
262
  background: var(--c_bg);
263
263
  border: 1px solid var(--c_border);
@@ -29,7 +29,10 @@ export default class HtmlEditor extends ShadowComponent {
29
29
  nodes: { type: String },
30
30
  hasTopToolbar: { type: Boolean, state: true },
31
31
  hasBottomToolbar: { type: Boolean, state: true },
32
- fullscreen: { type: Boolean, reflect: true }
32
+ fullscreen: { type: Boolean, reflect: true },
33
+ disabled: { type: Boolean, reflect: true },
34
+ readonly: { type: Boolean, reflect: true },
35
+ required: { type: Boolean, reflect: true }
33
36
  };
34
37
 
35
38
  constructor() {
@@ -57,6 +60,9 @@ export default class HtmlEditor extends ShadowComponent {
57
60
  this.minimapEnabled = false;
58
61
  this.fontSize = 14;
59
62
  this.fullscreen = false;
63
+ this.disabled = false;
64
+ this.readonly = false;
65
+ this.required = false;
60
66
  this.lx = {};
61
67
  this.debouncedSyncValue = debounce(() => this.syncValueFromLexical(), 300);
62
68
  }
@@ -130,6 +136,35 @@ export default class HtmlEditor extends ShadowComponent {
130
136
  if(changedProperties.has('fullscreen')){
131
137
  requestAnimationFrame(() => this.monacoEditor?.layout());
132
138
  }
139
+ if(changedProperties.has('disabled') || changedProperties.has('readonly')){
140
+ const ro = this.disabled || this.readonly;
141
+ this.lexicalEditor?.setEditable(!ro);
142
+ this.monacoEditor?.updateOptions({ readOnly: ro });
143
+ }
144
+ if(
145
+ changedProperties.has('value') ||
146
+ changedProperties.has('required') ||
147
+ changedProperties.has('disabled')
148
+ ){
149
+ this.#updateValidity();
150
+ }
151
+ }
152
+
153
+ #updateValidity = () => {
154
+ if(this.disabled){
155
+ this.internals.setValidity({});
156
+ return;
157
+ }
158
+ const empty = !(this.value || '').replace(/<[^>]+>/g, '').trim();
159
+ if(this.required && empty){
160
+ this.internals.setValidity(
161
+ { valueMissing: true },
162
+ 'Please fill out this field.',
163
+ this.lexicalContainer || this
164
+ );
165
+ } else {
166
+ this.internals.setValidity({});
167
+ }
133
168
  }
134
169
 
135
170
  async firstUpdated() {
@@ -268,6 +303,7 @@ export default class HtmlEditor extends ShadowComponent {
268
303
 
269
304
  this.lexicalEditor = lexical.createEditor(editorConfig);
270
305
  this.lexicalEditor.setRootElement(this.lexicalContainer);
306
+ if(this.disabled || this.readonly) this.lexicalEditor.setEditable(false);
271
307
  this.lexicalEditor._window = new Proxy(window, {
272
308
  get: (target, prop) => {
273
309
  if(prop === 'getSelection') return () => this.shadowRoot.getSelection();
@@ -356,7 +392,8 @@ export default class HtmlEditor extends ShadowComponent {
356
392
  scrollBeyondLastLine: false,
357
393
  automaticLayout: true,
358
394
  tabSize: 2,
359
- padding: { top: 8 }
395
+ padding: { top: 8 },
396
+ readOnly: this.disabled || this.readonly
360
397
  });
361
398
 
362
399
  const monacoCSS = document.querySelector('link[href*="monaco"][href*="editor.main.css"]');
@@ -491,6 +528,10 @@ export default class HtmlEditor extends ShadowComponent {
491
528
  this.value = state;
492
529
  }
493
530
 
531
+ formDisabledCallback(disabled) {
532
+ this.disabled = disabled;
533
+ }
534
+
494
535
  /*
495
536
  Selection Management
496
537
  */
@@ -1147,7 +1188,7 @@ export default class HtmlEditor extends ShadowComponent {
1147
1188
  <div class="editor-container">
1148
1189
  <div
1149
1190
  class="lexical-editor"
1150
- contenteditable="true"
1191
+ contenteditable=${(this.disabled || this.readonly) ? 'false' : 'true'}
1151
1192
  ?hidden=${this.mode !== 'visual'}
1152
1193
  ></div>
1153
1194
  <div
@@ -1185,6 +1226,26 @@ export default class HtmlEditor extends ShadowComponent {
1185
1226
  height: 100vh !important;
1186
1227
  z-index: 10000;
1187
1228
  }
1229
+ :host([disabled]) {
1230
+ opacity: 0.6;
1231
+ }
1232
+ /* disabled blocks all interaction -- toolbar AND editor. Lexical
1233
+ and Monaco both prevent typing on their own; pointer-events: none
1234
+ also stops focus / cursor placement, matching native form control
1235
+ semantics. */
1236
+ :host([disabled]) .toolbar-top,
1237
+ :host([disabled]) .toolbar-bottom,
1238
+ :host([disabled]) .editor-container {
1239
+ pointer-events: none;
1240
+ }
1241
+ /* readonly keeps the editor interactive (so users can place a cursor
1242
+ to select / copy) but mutes the toolbar so its buttons can't
1243
+ mutate the document. */
1244
+ :host([readonly]) .toolbar-top,
1245
+ :host([readonly]) .toolbar-bottom {
1246
+ pointer-events: none;
1247
+ opacity: 0.5;
1248
+ }
1188
1249
  .toolbar-top,
1189
1250
  .toolbar-bottom {
1190
1251
  display: flex;
@@ -1219,7 +1280,7 @@ export default class HtmlEditor extends ShadowComponent {
1219
1280
  overflow: auto;
1220
1281
  }
1221
1282
  .lexical-editor {
1222
- padding: 1rem;
1283
+ padding: var(--editor_padding, 1rem);
1223
1284
  border: 1px solid var(--border-color);
1224
1285
  background: var(--bg-primary);
1225
1286
  color: var(--text-primary);
@@ -1227,6 +1288,25 @@ export default class HtmlEditor extends ShadowComponent {
1227
1288
  font-size: inherit;
1228
1289
  line-height: 1.5;
1229
1290
  outline: none;
1291
+ /* Always show a slim scrollbar when content overflows (macOS would
1292
+ otherwise hide overlay scrollbars when not actively scrolling,
1293
+ leaving users unaware they can scroll back). */
1294
+ scrollbar-width: thin;
1295
+ scrollbar-color: var(--c_border, rgba(128,128,128,0.4)) transparent;
1296
+ }
1297
+ .lexical-editor::-webkit-scrollbar {
1298
+ width: 8px;
1299
+ height: 8px;
1300
+ }
1301
+ .lexical-editor::-webkit-scrollbar-track {
1302
+ background: transparent;
1303
+ }
1304
+ .lexical-editor::-webkit-scrollbar-thumb {
1305
+ background: var(--c_border, rgba(128,128,128,0.4));
1306
+ border-radius: 4px;
1307
+ }
1308
+ .lexical-editor::-webkit-scrollbar-thumb:hover {
1309
+ background: rgba(128,128,128,0.7);
1230
1310
  }
1231
1311
  .lexical-editor:focus {
1232
1312
  border-color: var(--primary-color);