@ni/ok-components 1.5.1 → 1.5.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.
@@ -100086,6 +100086,7 @@ focus outline in that case.
100086
100086
  ${display$1('flex')}
100087
100087
 
100088
100088
  :host {
100089
+ height: 480px;
100089
100090
  flex-direction: column;
100090
100091
  background: ${applicationBackgroundColor};
100091
100092
  }
@@ -100133,12 +100134,28 @@ focus outline in that case.
100133
100134
  flex: 1;
100134
100135
  display: flex;
100135
100136
  flex-direction: column;
100136
- justify-content: flex-start;
100137
100137
  row-gap: 32px;
100138
100138
  padding: ${mediumPadding} ${standardPadding} ${mediumPadding}
100139
100139
  ${standardPadding};
100140
100140
  background: ${sectionBackgroundImage};
100141
100141
  overflow-y: auto;
100142
+ overflow-anchor: none;
100143
+ }
100144
+
100145
+ .messages-history,
100146
+ .messages-anchored {
100147
+ flex: none;
100148
+ display: flex;
100149
+ flex-direction: column;
100150
+ row-gap: 32px;
100151
+ }
100152
+
100153
+ .messages-history.region-empty {
100154
+ display: none;
100155
+ }
100156
+
100157
+ .messages-anchored.anchor-active {
100158
+ min-height: 100%;
100142
100159
  }
100143
100160
 
100144
100161
  :host([appearance='overlay']) .messages {
@@ -100162,7 +100179,14 @@ focus outline in that case.
100162
100179
  <div class="start ${x => (x.startEmpty ? 'start-empty' : '')}">
100163
100180
  <slot name="start" ${slotted({ property: 'slottedStartElements' })}></slot>
100164
100181
  </div>
100165
- <div class="messages"><slot></slot></div>
100182
+ <div class="messages" ${ref('messagesContainer')}>
100183
+ <div class="messages-history ${x => (x.historyEmpty ? 'region-empty' : '')}">
100184
+ <slot name="history" ${slotted({ property: 'slottedHistoryMessages' })}></slot>
100185
+ </div>
100186
+ <div class="messages-anchored ${x => (x.autoScrollManager.anchorActive ? 'anchor-active' : '')}" ${ref('anchoredContainer')}>
100187
+ <slot ${slotted({ property: 'slottedMessages' })}></slot>
100188
+ </div>
100189
+ </div>
100166
100190
  <div class="input ${x => (x.inputEmpty ? 'input-empty' : '')}">
100167
100191
  <slot name="input" ${slotted({ property: 'slottedInputElements' })}>
100168
100192
  </slot>
@@ -100179,6 +100203,262 @@ focus outline in that case.
100179
100203
  const ChatConversationAppearance = {
100180
100204
  default: undefined};
100181
100205
 
100206
+ /**
100207
+ * Internal state for a chat message
100208
+ * @internal
100209
+ */
100210
+ class ChatMessageInternals {
100211
+ constructor(host, options) {
100212
+ /**
100213
+ * True when this message is the one the conversation anchors to while
100214
+ * auto-scrolling.
100215
+ */
100216
+ this.isScrollAnchor = false;
100217
+ this.host = host;
100218
+ this.anchorOnInsert = options?.anchorOnInsert ?? false;
100219
+ }
100220
+ get slot() {
100221
+ return this.slotName;
100222
+ }
100223
+ set slot(value) {
100224
+ if (value === this.slotName) {
100225
+ return;
100226
+ }
100227
+ this.slotName = value;
100228
+ if (value === undefined) {
100229
+ this.host.removeAttribute('slot');
100230
+ }
100231
+ else {
100232
+ this.host.setAttribute('slot', value);
100233
+ }
100234
+ }
100235
+ static elementHasMessageInternals(element) {
100236
+ return element.messageInternals instanceof ChatMessageInternals;
100237
+ }
100238
+ }
100239
+ __decorate([
100240
+ observable
100241
+ ], ChatMessageInternals.prototype, "isScrollAnchor", void 0);
100242
+
100243
+ // Distance from the bottom (px) within which the conversation is considered "at the bottom".
100244
+ const scrollingPixelThreshold = 10;
100245
+ // Slot name for messages that precede the current turn's anchor message.
100246
+ const historySlotName = 'history';
100247
+ /**
100248
+ * Manages auto-scroll behavior for the chat conversation:
100249
+ * - Splits messages into `default` and `history` slots so the top message of the
100250
+ * `default` slot becomes the anchor: it is moved to the top of the viewport and scrolled to on insert
100251
+ * - Implements auto scroll when at the bottom of the window as content is added until the user scrolls away
100252
+ * @internal
100253
+ */
100254
+ class AutoScrollManager {
100255
+ get isActive() {
100256
+ return this.resizeObserver !== undefined;
100257
+ }
100258
+ constructor(conversation) {
100259
+ this.conversation = conversation;
100260
+ /**
100261
+ * Whether auto-scroll is currently following new content. Set to false when
100262
+ * the user scrolls away from the bottom and back to true when they return.
100263
+ */
100264
+ this.autoScrollEngaged = true;
100265
+ /**
100266
+ * Whether the anchored region is reserving a viewport of space
100267
+ */
100268
+ this.anchorActive = false;
100269
+ this.scrollUpdatePending = false;
100270
+ this.pendingAnchorInsert = false;
100271
+ this.previousMessages = [];
100272
+ this.onScroll = () => {
100273
+ const container = this.conversation.messagesContainer;
100274
+ if (this.programmaticScrollTarget !== undefined) {
100275
+ // The programmatic scroll always targets the bottom, so treat it as
100276
+ // settled once we reach that target or the bottom itself.
100277
+ const reachedTarget = Math.abs(container.scrollTop - this.programmaticScrollTarget) <= 1;
100278
+ const reachedBottom = this.getDistanceFromBottom() <= scrollingPixelThreshold;
100279
+ if (reachedTarget || reachedBottom) {
100280
+ this.programmaticScrollTarget = undefined;
100281
+ }
100282
+ return;
100283
+ }
100284
+ this.autoScrollEngaged = this.getDistanceFromBottom() <= scrollingPixelThreshold;
100285
+ };
100286
+ this.conversationNotifier = Observable.getNotifier(this.conversation);
100287
+ }
100288
+ connect() {
100289
+ this.autoScrollEngaged = true;
100290
+ this.previousMessages = this.getOrderedMessages();
100291
+ this.conversationNotifier.subscribe(this, 'slottedMessages');
100292
+ this.conversationNotifier.subscribe(this, 'slottedHistoryMessages');
100293
+ this.conversation.messagesContainer.addEventListener('scroll', this.onScroll, { passive: true });
100294
+ this.resizeObserver = new ResizeObserver(() => {
100295
+ this.onContentSizeChanged();
100296
+ });
100297
+ // Observe the anchored region for streamed content growth and the scroll
100298
+ // viewport so the conversation stays pinned when its height changes.
100299
+ this.resizeObserver.observe(this.conversation.anchoredContainer);
100300
+ this.resizeObserver.observe(this.conversation.messagesContainer);
100301
+ this.repartition(this.previousMessages);
100302
+ }
100303
+ disconnect() {
100304
+ this.conversationNotifier.unsubscribe(this, 'slottedMessages');
100305
+ this.conversationNotifier.unsubscribe(this, 'slottedHistoryMessages');
100306
+ this.conversation.messagesContainer.removeEventListener('scroll', this.onScroll);
100307
+ this.resizeObserver?.disconnect();
100308
+ this.resizeObserver = undefined;
100309
+ this.setScrollAnchorMessage(undefined);
100310
+ this.clearSlotAssignments();
100311
+ this.anchorActive = false;
100312
+ this.previousMessages = [];
100313
+ }
100314
+ handleChange(source, args) {
100315
+ if (source === this.conversation
100316
+ && (args === 'slottedMessages' || args === 'slottedHistoryMessages')) {
100317
+ this.onMessagesChanged();
100318
+ }
100319
+ }
100320
+ onMessagesChanged() {
100321
+ const current = this.getOrderedMessages();
100322
+ const previousSet = new Set(this.previousMessages);
100323
+ const addedMessages = current.filter(message => !previousSet.has(message));
100324
+ this.previousMessages = current;
100325
+ this.repartition(current);
100326
+ if (addedMessages.length === 0) {
100327
+ return;
100328
+ }
100329
+ const hasAnchorMessage = addedMessages.some(message => message.messageInternals.anchorOnInsert);
100330
+ this.scheduleScrollUpdate(hasAnchorMessage);
100331
+ }
100332
+ repartition(messages) {
100333
+ const anchorIndex = this.findLatestAnchorIndex(messages);
100334
+ messages.forEach((message, index) => {
100335
+ message.messageInternals.slot = anchorIndex >= 0 && index < anchorIndex
100336
+ ? historySlotName
100337
+ : undefined;
100338
+ });
100339
+ this.anchorActive = anchorIndex >= 0;
100340
+ }
100341
+ clearSlotAssignments() {
100342
+ for (const message of this.getOrderedMessages()) {
100343
+ message.messageInternals.slot = undefined;
100344
+ }
100345
+ }
100346
+ scheduleScrollUpdate(hasAnchorMessage) {
100347
+ this.pendingAnchorInsert = this.pendingAnchorInsert || hasAnchorMessage;
100348
+ if (this.scrollUpdatePending) {
100349
+ return;
100350
+ }
100351
+ this.scrollUpdatePending = true;
100352
+ requestAnimationFrame(() => {
100353
+ this.scrollUpdatePending = false;
100354
+ const anchorInsert = this.pendingAnchorInsert;
100355
+ this.pendingAnchorInsert = false;
100356
+ if (anchorInsert) {
100357
+ this.anchorToLastInsertedMessage();
100358
+ }
100359
+ else if (this.autoScrollEngaged) {
100360
+ this.followContent();
100361
+ }
100362
+ });
100363
+ }
100364
+ /**
100365
+ * Pins the most recently inserted anchor message near the top of the
100366
+ * viewport.
100367
+ */
100368
+ anchorToLastInsertedMessage() {
100369
+ const message = this.getLastAnchorMessage();
100370
+ if (message === undefined) {
100371
+ return;
100372
+ }
100373
+ this.setScrollAnchorMessage(message);
100374
+ this.autoScrollEngaged = true;
100375
+ this.smoothScrollTo(this.getMaxScrollTop());
100376
+ }
100377
+ followContent() {
100378
+ this.instantScrollTo(this.getMaxScrollTop());
100379
+ }
100380
+ onContentSizeChanged() {
100381
+ // Reacts to streamed content growth and viewport height changes.
100382
+ // While a pending or in-progress anchor insert owns positioning, let its
100383
+ // smooth scroll settle instead of competing with an instant follow.
100384
+ if (this.pendingAnchorInsert
100385
+ || this.programmaticScrollTarget !== undefined) {
100386
+ return;
100387
+ }
100388
+ if (this.autoScrollEngaged) {
100389
+ this.followContent();
100390
+ }
100391
+ }
100392
+ getDistanceFromBottom() {
100393
+ const { scrollTop, scrollHeight, clientHeight } = this.conversation.messagesContainer;
100394
+ return scrollHeight - scrollTop - clientHeight;
100395
+ }
100396
+ getMaxScrollTop() {
100397
+ const { scrollHeight, clientHeight } = this.conversation.messagesContainer;
100398
+ return Math.max(0, scrollHeight - clientHeight);
100399
+ }
100400
+ smoothScrollTo(scrollTop) {
100401
+ const container = this.conversation.messagesContainer;
100402
+ if (Math.abs(container.scrollTop - scrollTop) <= 1) {
100403
+ // No movement is needed, so `scrollTo` would not emit a scroll event
100404
+ // to clear the programmatic guard. Snap to the exact target and
100405
+ // leave the guard clear so streamed content keeps being followed.
100406
+ this.programmaticScrollTarget = undefined;
100407
+ container.scrollTop = scrollTop;
100408
+ return;
100409
+ }
100410
+ this.programmaticScrollTarget = scrollTop;
100411
+ container.scrollTo({
100412
+ top: scrollTop,
100413
+ behavior: 'smooth'
100414
+ });
100415
+ }
100416
+ instantScrollTo(scrollTop) {
100417
+ this.conversation.messagesContainer.scrollTop = scrollTop;
100418
+ }
100419
+ setScrollAnchorMessage(message) {
100420
+ if (this.scrollAnchorMessage === message) {
100421
+ return;
100422
+ }
100423
+ if (this.scrollAnchorMessage !== undefined) {
100424
+ this.scrollAnchorMessage.messageInternals.isScrollAnchor = false;
100425
+ }
100426
+ this.scrollAnchorMessage = message;
100427
+ if (message !== undefined) {
100428
+ message.messageInternals.isScrollAnchor = true;
100429
+ }
100430
+ }
100431
+ getLastAnchorMessage() {
100432
+ const messages = this.getOrderedMessages();
100433
+ const index = this.findLatestAnchorIndex(messages);
100434
+ return index >= 0 ? messages[index] : undefined;
100435
+ }
100436
+ findLatestAnchorIndex(messages) {
100437
+ for (let i = messages.length - 1; i >= 0; i--) {
100438
+ const message = messages[i];
100439
+ if (message?.messageInternals.anchorOnInsert) {
100440
+ return i;
100441
+ }
100442
+ }
100443
+ return -1;
100444
+ }
100445
+ getOrderedMessages() {
100446
+ const messages = [];
100447
+ for (const child of Array.from(this.conversation.children)) {
100448
+ if (ChatMessageInternals.elementHasMessageInternals(child)) {
100449
+ messages.push(child);
100450
+ }
100451
+ }
100452
+ return messages;
100453
+ }
100454
+ }
100455
+ __decorate([
100456
+ observable
100457
+ ], AutoScrollManager.prototype, "autoScrollEngaged", void 0);
100458
+ __decorate([
100459
+ observable
100460
+ ], AutoScrollManager.prototype, "anchorActive", void 0);
100461
+
100182
100462
  /**
100183
100463
  * A Spright component for displaying a series of chat messages
100184
100464
  */
@@ -100186,6 +100466,15 @@ focus outline in that case.
100186
100466
  constructor() {
100187
100467
  super(...arguments);
100188
100468
  this.appearance = ChatConversationAppearance.default;
100469
+ this.autoScroll = false;
100470
+ /**
100471
+ * Manages auto-scroll behavior. Always present; its observers are registered
100472
+ * while the conversation is connected and `autoScroll` is enabled.
100473
+ * @internal
100474
+ */
100475
+ this.autoScrollManager = new AutoScrollManager(this);
100476
+ /** @internal */
100477
+ this.historyEmpty = true;
100189
100478
  /** @internal */
100190
100479
  this.inputEmpty = true;
100191
100480
  /** @internal */
@@ -100195,6 +100484,31 @@ focus outline in that case.
100195
100484
  /** @internal */
100196
100485
  this.endEmpty = true;
100197
100486
  }
100487
+ connectedCallback() {
100488
+ super.connectedCallback();
100489
+ if (this.autoScroll) {
100490
+ this.autoScrollManager.connect();
100491
+ }
100492
+ }
100493
+ disconnectedCallback() {
100494
+ super.disconnectedCallback();
100495
+ if (this.autoScroll) {
100496
+ this.autoScrollManager.disconnect();
100497
+ }
100498
+ }
100499
+ autoScrollChanged() {
100500
+ if (this.$fastController.isConnected) {
100501
+ if (this.autoScroll) {
100502
+ this.autoScrollManager.connect();
100503
+ }
100504
+ else {
100505
+ this.autoScrollManager.disconnect();
100506
+ }
100507
+ }
100508
+ }
100509
+ slottedHistoryMessagesChanged(_prev, next) {
100510
+ this.historyEmpty = next === undefined || next.length === 0;
100511
+ }
100198
100512
  slottedInputElementsChanged(_prev, next) {
100199
100513
  this.inputEmpty = next === undefined || next.length === 0;
100200
100514
  }
@@ -100211,6 +100525,18 @@ focus outline in that case.
100211
100525
  __decorate([
100212
100526
  attr
100213
100527
  ], ChatConversation.prototype, "appearance", void 0);
100528
+ __decorate([
100529
+ attr({ attribute: 'auto-scroll', mode: 'boolean' })
100530
+ ], ChatConversation.prototype, "autoScroll", void 0);
100531
+ __decorate([
100532
+ observable
100533
+ ], ChatConversation.prototype, "slottedMessages", void 0);
100534
+ __decorate([
100535
+ observable
100536
+ ], ChatConversation.prototype, "slottedHistoryMessages", void 0);
100537
+ __decorate([
100538
+ observable
100539
+ ], ChatConversation.prototype, "historyEmpty", void 0);
100214
100540
  __decorate([
100215
100541
  observable
100216
100542
  ], ChatConversation.prototype, "inputEmpty", void 0);
@@ -100870,6 +101196,8 @@ focus outline in that case.
100870
101196
  constructor() {
100871
101197
  super(...arguments);
100872
101198
  /** @internal */
101199
+ this.messageInternals = new ChatMessageInternals(this);
101200
+ /** @internal */
100873
101201
  this.footerActionsIsEmpty = true;
100874
101202
  }
100875
101203
  slottedFooterActionsElementsChanged(_prev, next) {
@@ -100936,6 +101264,13 @@ focus outline in that case.
100936
101264
  * A Spright component for displaying an outbound chat message
100937
101265
  */
100938
101266
  class ChatMessageOutbound extends FoundationElement {
101267
+ constructor() {
101268
+ super(...arguments);
101269
+ /** @internal */
101270
+ this.messageInternals = new ChatMessageInternals(this, {
101271
+ anchorOnInsert: true
101272
+ });
101273
+ }
100939
101274
  }
100940
101275
  const sprightChatMessageOutbound = ChatMessageOutbound.compose({
100941
101276
  baseName: 'chat-message-outbound',
@@ -100983,6 +101318,11 @@ focus outline in that case.
100983
101318
  * A Spright component for displaying an system chat message
100984
101319
  */
100985
101320
  class ChatMessageSystem extends FoundationElement {
101321
+ constructor() {
101322
+ super(...arguments);
101323
+ /** @internal */
101324
+ this.messageInternals = new ChatMessageInternals(this);
101325
+ }
100986
101326
  }
100987
101327
  const sprightChatMessageSystem = ChatMessageSystem.compose({
100988
101328
  baseName: 'chat-message-system',