diff --git a/examples/Demo/FluentUI.Demo.Client/DebugPages/Test.razor b/examples/Demo/FluentUI.Demo.Client/DebugPages/Test.razor
index 31129d097c..e8ac45c051 100644
--- a/examples/Demo/FluentUI.Demo.Client/DebugPages/Test.razor
+++ b/examples/Demo/FluentUI.Demo.Client/DebugPages/Test.razor
@@ -1,10 +1,36 @@
@page "/Debug/Test"
Test
+
+
-
+
+
+
+
+
+
-@code {
- public string test { get; set; } = "test";
-}
+
+
+
+
+
+
+
+
+
diff --git a/src/Core.Scripts/src/Components/PageScript/FluentPageScript.ts b/src/Core.Scripts/src/Components/PageScript/FluentPageScript.ts
index 3fcd94080a..fb47f1f485 100644
--- a/src/Core.Scripts/src/Components/PageScript/FluentPageScript.ts
+++ b/src/Core.Scripts/src/Components/PageScript/FluentPageScript.ts
@@ -1,96 +1,95 @@
import { StartedMode } from "../../d-ts/StartedMode";
-export namespace Microsoft.FluentUI.Blazor.Components.PageScript {
+const pageScriptInfoBySrc = new Map();
- const pageScriptInfoBySrc = new Map();
+export class FluentPageScript extends HTMLElement {
+ static observedAttributes = ['src'];
+ src: string | null = null;
- export class FluentPageScript extends HTMLElement {
- static observedAttributes = ['src'];
- src: string | null = null;
-
- attributeChangedCallback(name: string | null, oldValue: string | null, newValue: string | null): void {
- if (name !== 'src') {
- return;
- }
-
- this.src = newValue;
- this.unregisterPageScriptElement(oldValue);
- this.registerPageScriptElement(newValue);
+ /**
+ * Register the FluentPageScript component
+ * @param blazor
+ * @param mode
+ */
+ public static registerComponent = (blazor: Blazor, mode: StartedMode): void => {
+ if (typeof blazor.addEventListener === 'function' && mode === StartedMode.Web) {
+ customElements.define('fluent-page-script', FluentPageScript);
+ blazor.addEventListener('enhancedload', onEnhancedLoad);
}
+ };
- disconnectedCallback(): void {
- this.unregisterPageScriptElement(this.src);
+ attributeChangedCallback(name: string | null, oldValue: string | null, newValue: string | null): void {
+ if (name !== 'src') {
+ return;
}
- registerPageScriptElement(src: string | null): void {
- if (!src) {
- throw new Error('Must provide a non-empty value for the "src" attribute.');
- }
+ this.src = newValue;
+ this.unregisterPageScriptElement(oldValue);
+ this.registerPageScriptElement(newValue);
+ }
- let pageScriptInfo = pageScriptInfoBySrc.get(src);
+ disconnectedCallback(): void {
+ this.unregisterPageScriptElement(this.src);
+ }
- if (pageScriptInfo) {
- pageScriptInfo.referenceCount++;
- } else {
- pageScriptInfo = { referenceCount: 1, module: null };
- pageScriptInfoBySrc.set(src, pageScriptInfo);
- this.initializePageScriptModule(src, pageScriptInfo);
- }
+ registerPageScriptElement(src: string | null): void {
+ if (!src) {
+ throw new Error('Must provide a non-empty value for the "src" attribute.');
}
- unregisterPageScriptElement(src: string | null): void {
- if (!src) {
- return;
- }
-
- const pageScriptInfo = pageScriptInfoBySrc.get(src);
- if (!pageScriptInfo) {
- return;
- }
+ let pageScriptInfo = pageScriptInfoBySrc.get(src);
- pageScriptInfo.referenceCount--;
+ if (pageScriptInfo) {
+ pageScriptInfo.referenceCount++;
+ } else {
+ pageScriptInfo = { referenceCount: 1, module: null };
+ pageScriptInfoBySrc.set(src, pageScriptInfo);
+ this.initializePageScriptModule(src, pageScriptInfo);
}
+ }
- async initializePageScriptModule(src: string, pageScriptInfo: any): void {
- if (src.startsWith("./")) {
- src = new URL(src.substring(2), document.baseURI).toString();
- }
-
- const module = await import(src);
-
- if (pageScriptInfo.referenceCount <= 0) {
- return;
- }
+ unregisterPageScriptElement(src: string | null): void {
+ if (!src) {
+ return;
+ }
- pageScriptInfo.module = module;
- module.onLoad?.();
- module.onUpdate?.();
+ const pageScriptInfo = pageScriptInfoBySrc.get(src);
+ if (!pageScriptInfo) {
+ return;
}
+
+ pageScriptInfo.referenceCount--;
}
- const onEnhancedLoad = (): void => {
- for (const [src, { module, referenceCount }] of pageScriptInfoBySrc) {
- if (referenceCount <= 0) {
- module?.onDispose?.();
- pageScriptInfoBySrc.delete(src);
- }
+ async initializePageScriptModule(src: string, pageScriptInfo: any): void {
+ if (src.startsWith("./")) {
+ src = new URL(src.substring(2), document.baseURI).toString();
}
- for (const { module } of pageScriptInfoBySrc.values()) {
- module?.onUpdate?.();
+ const module = await import(src);
+
+ if (pageScriptInfo.referenceCount <= 0) {
+ return;
}
+
+ pageScriptInfo.module = module;
+ module.onLoad?.();
+ module.onUpdate?.();
}
+}
- /**
- * Register the FluentPageScript component
- * @param blazor
- * @param mode
- */
- export const registerComponent = (blazor: Blazor, mode: StartedMode): void => {
- if (typeof blazor.addEventListener === 'function' && mode === StartedMode.Web) {
- customElements.define('fluent-page-script', FluentPageScript);
- blazor.addEventListener('enhancedload', onEnhancedLoad);
+const onEnhancedLoad = (): void => {
+ for (const [src, { module, referenceCount }] of pageScriptInfoBySrc) {
+ if (referenceCount <= 0) {
+ module?.onDispose?.();
+ pageScriptInfoBySrc.delete(src);
}
- };
+ }
+ for (const { module } of pageScriptInfoBySrc.values()) {
+ module?.onUpdate?.();
+ }
}
+
+
+
diff --git a/src/Core.Scripts/src/Components/TextSuggestion/Caret-xy.ts b/src/Core.Scripts/src/Components/TextSuggestion/Caret-xy.ts
new file mode 100644
index 0000000000..10f2acbd8f
--- /dev/null
+++ b/src/Core.Scripts/src/Components/TextSuggestion/Caret-xy.ts
@@ -0,0 +1,154 @@
+/*
+ Original source from https://github.com/tnhu/caret-xy
+*/
+
+const root = document.documentElement
+const body = document.body
+let remToPixelRatio: any
+
+export interface CaretPosition {
+ top: number
+ left: number
+ height: number
+}
+
+function toPixels(value: any, elementFontSize: any) {
+ var pixels = parseFloat(value)
+
+ if (value.indexOf('pt') !== -1) {
+ pixels = pixels * 4 / 3
+ } else if (value.indexOf('mm') !== -1) {
+ pixels = pixels * 96 / 25.4
+ } else if (value.indexOf('cm') !== -1) {
+ pixels = pixels * 96 / 2.54
+ } else if (value.indexOf('in') !== -1) {
+ pixels *= 96
+ } else if (value.indexOf('pc') !== -1) {
+ pixels *= 16
+ } else if (value.indexOf('rem') !== -1) {
+ if (!remToPixelRatio) {
+ remToPixelRatio = parseFloat(getComputedStyle(root).fontSize)
+ }
+ pixels *= remToPixelRatio
+ } else if (value.indexOf('em') !== -1) {
+ pixels = elementFontSize ? pixels * parseFloat(elementFontSize) : toPixels(pixels + 'rem', elementFontSize)
+ }
+
+ return pixels
+}
+
+function lineHeightInPixels(lineHeight: any, elementFontSize: any) {
+ return lineHeight === 'normal' ? 1.2 * parseInt(elementFontSize, 10) : toPixels(lineHeight, elementFontSize)
+}
+
+// Original source from `textarea-caret-position`
+// https://github.com/component/textarea-caret-position
+// MIT, Copyright (c) 2015 Jonathan Ong me@jongleberry.com
+
+// The attributes that we copy into a mirrored div.
+// Note that some browsers, such as Firefox,
+// do not concatenate properties, i.e. padding-top, bottom etc. -> padding,
+// so we have to do every single property specifically.
+const mirrorAttributes = [
+ 'direction', // RTL support
+ 'boxSizing',
+ 'width', // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does
+ 'height',
+ 'overflowX',
+ 'overflowY', // copy the scrollbar for IE
+
+ 'borderTopWidth',
+ 'borderRightWidth',
+ 'borderBottomWidth',
+ 'borderLeftWidth',
+ 'borderStyle',
+
+ 'paddingTop',
+ 'paddingRight',
+ 'paddingBottom',
+ 'paddingLeft',
+
+ // https://developer.mozilla.org/en-US/docs/Web/CSS/font
+ 'fontStyle',
+ 'fontVariant',
+ 'fontWeight',
+ 'fontStretch',
+ 'fontSize',
+ 'fontSizeAdjust',
+ 'lineHeight',
+ 'fontFamily',
+
+ 'textAlign',
+ 'textTransform',
+ 'textIndent',
+ 'textDecoration', // might not make a difference, but better be safe
+
+ 'varterSpacing',
+ 'wordSpacing',
+
+ 'tabSize',
+ 'MozTabSize'
+]
+
+function getMirrorInfo(element: any, isInput: any) {
+ if (element.mirrorInfo) {
+ return element.mirrorInfo
+ }
+
+ const div = document.createElement('div')
+ const style: any = div.style
+ const computedStyles: any = getComputedStyle(element)
+ const hidden = 'hidden'
+ const focusOut = 'focusout'
+
+ style.whiteSpace = 'pre-wrap'
+ if (!isInput) style.wordWrap = 'break-word' // only for textarea
+
+ style.position = 'absolute'
+ style.visibility = hidden // not 'display: none' because we want rendering
+
+ mirrorAttributes.forEach(prop => style[prop] = computedStyles[prop])
+ style.overflow = hidden // Do we need to copy overflowX, overflowY if this is set?
+
+ if (isInput) {
+ style.whiteSpace = 'nowrap'
+ }
+
+ body.appendChild(div)
+
+ // Cache mirror info so we don't create elements and invoke getComputedStyle() again and again
+ element.mirrorInfo = { div, span: document.createElement('span'), computedStyles }
+
+ // Remove cached mirror div when element is out of focus
+ element.addEventListener(focusOut, function cleanup() {
+ delete element.mirrorInfo
+ body.removeChild(div)
+ element.removeEventListener(focusOut, cleanup)
+ })
+
+ return element.mirrorInfo
+}
+
+export default function caretXY(element: any, position = element.selectionEnd): CaretPosition {
+ const isInput = element.nodeName.toLowerCase() === 'input'
+ const { div, span, computedStyles } = getMirrorInfo(element, isInput)
+ const content = element.value.substring(0, position)
+
+ // For input, text content needs to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037
+ div.textContent = isInput ? content.replace(/\s/g, '\u00a0') : content
+
+ // Wrapping must be replicated *exactly*, including when a long word gets
+ // onto the next line, with whitespace at the end of the line before (#7).
+ // The *only* reliable way to do that is to copy the *entire* rest of the
+ // textarea content into the created at the caret position.
+ // for inputs, just '.' would be enough, but why bother?
+ span.textContent = element.value.substring(position) || '.' // || because a completely empty faux span doesn't render at all
+ div.appendChild(span)
+
+ const rect = element.getBoundingClientRect()
+ const top = span.offsetTop + parseInt(computedStyles.borderTopWidth) - element.scrollTop + rect.top
+ const left = span.offsetLeft + parseInt(computedStyles.borderLeftWidth) - element.scrollLeft + rect.left
+ const height = lineHeightInPixels(computedStyles.lineHeight, computedStyles.fontSize)
+
+ return { top, left, height }
+}
diff --git a/src/Core.Scripts/src/Components/TextSuggestion/CaretUtil.ts b/src/Core.Scripts/src/Components/TextSuggestion/CaretUtil.ts
new file mode 100644
index 0000000000..e7c2b591d5
--- /dev/null
+++ b/src/Core.Scripts/src/Components/TextSuggestion/CaretUtil.ts
@@ -0,0 +1,38 @@
+import caretXY from "./Caret-xy";
+
+export function scrollTextAreaDownToCaretIfNeeded(textArea: HTMLTextAreaElement | HTMLInputElement) {
+ // Note that this only scrolls *down*, because that's the only scenario after a suggestion is accepted
+ const pos = caretXY(textArea);
+ const lineHeightInPixels = parseFloat(window.getComputedStyle(textArea).lineHeight);
+ if (pos.top > textArea.clientHeight + textArea.scrollTop - lineHeightInPixels) {
+ textArea.scrollTop = pos.top - textArea.clientHeight + lineHeightInPixels;
+ }
+}
+
+export function getCaretOffsetFromOffsetParent(elem: HTMLTextAreaElement | HTMLInputElement): { top: number, left: number, height: number, elemStyle: CSSStyleDeclaration } {
+ const elemStyle = window.getComputedStyle(elem);
+ const pos = caretXY(elem);
+
+ return {
+ top: pos.top, // + parseFloat(elemStyle.borderTopWidth) + elem.offsetTop - elem.scrollTop,
+ left: pos.left, // + parseFloat(elemStyle.borderLeftWidth) + elem.offsetLeft - elem.scrollLeft - 0.25,
+ height: pos.height,
+ elemStyle: elemStyle,
+ }
+}
+
+export function insertTextAtCaretPosition(textArea: HTMLTextAreaElement | HTMLInputElement, text: string) {
+ // Even though document.execCommand is deprecated, it's still the best way to insert text, because it's
+ // the only way that interacts correctly with the undo buffer. If we have to fall back on mutating
+ // the .value property directly, it works but erases the undo buffer.
+ if (document.execCommand) {
+ document.execCommand('insertText', false, text);
+ } else {
+ let caretPos = textArea.selectionStart ?? 0;
+ textArea.value = textArea.value.substring(0, caretPos)
+ + text
+ + textArea.value.substring(textArea.selectionEnd ?? 0);
+ caretPos += text.length;
+ textArea.setSelectionRange(caretPos, caretPos);
+ }
+}
diff --git a/src/Core.Scripts/src/Components/TextSuggestion/FluentTextSuggestion.ts b/src/Core.Scripts/src/Components/TextSuggestion/FluentTextSuggestion.ts
new file mode 100644
index 0000000000..40ee1b9ead
--- /dev/null
+++ b/src/Core.Scripts/src/Components/TextSuggestion/FluentTextSuggestion.ts
@@ -0,0 +1,316 @@
+import { StartedMode } from "../../d-ts/StartedMode";
+import { insertTextAtCaretPosition, scrollTextAreaDownToCaretIfNeeded } from "./CaretUtil";
+import { InlineSuggestionDisplay } from "./InlineSuggestionDisplay";
+import { SuggestionDisplay } from "./SuggestionDisplay";
+
+export class FluentTextSuggestion extends HTMLElement {
+
+ private typingDebounceTimeout: number | null = null;
+ private textArea!: HTMLTextAreaElement | HTMLInputElement;
+ private suggestionDisplay!: SuggestionDisplay;
+ private pendingSuggestionAbortController?: AbortController;
+ private isInitialized: boolean = false;
+
+ /**
+ * Register the FluentPageScript component
+ * @param blazor
+ * @param mode
+ */
+ public static registerComponent = (blazor: Blazor, mode: StartedMode): void => {
+ customElements.define('fluent-text-suggestion', FluentTextSuggestion);
+ };
+
+ /**
+ * Gets the value of the suggestion attribute.
+ */
+ get value(): string | null {
+ return this.getAttribute("value");
+ }
+
+ /**
+ * Sets the value of the suggestion attribute.
+ */
+ set value(value: string | null) {
+ this.updateAttribute("value", value);
+ }
+
+ /**
+ * Gets the identifier of the textarea element.
+ */
+ get anchor(): string | null {
+ return this.getAttribute("anchor");
+ }
+
+ /**
+ * Sets the identifier of the textarea element.
+ */
+ set anchor(value: string | null) {
+ this.updateAttribute("anchor", value);
+ }
+
+ /**
+ * Gets the minimum length of the text field that the user must enter for the suggestion to be displayed.
+ */
+ get minlength(): number | null {
+ return parseInt(this.getAttribute("minlength") ?? '0');
+ }
+
+ /**
+ * Sets the minimum length of the text field that the user must enter for the suggestion to be displayed.
+ */
+ set minlength(value: number | null) {
+ this.updateAttribute("minlength", value);
+ }
+
+ /**
+ * Gets the delay in milliseconds before the suggestion is displayed.
+ */
+ get delay(): number | null {
+ return parseInt(this.getAttribute("delay") ?? '350');
+ }
+
+ /**
+ * Sets the delay in milliseconds before the suggestion is displayed.
+ */
+ set delay(value: number | null) {
+ this.updateAttribute("delay", value);
+ }
+
+
+ /**
+ * Gets the QuerySelector of the input element, included in the shadow DOM.
+ */
+ get shadowQuerySelector(): string | null {
+ return this.getAttribute("shadowQuerySelector");
+ }
+
+ /**
+ * Sets the QuerySelector of the input element, included in the shadow DOM.
+ */
+ set shadowQuerySelector(value: string | null) {
+ this.updateAttribute("shadowQuerySelector", value);
+ }
+
+ // Custom element added to page.
+ connectedCallback() {
+ this.initializeTextArea();
+ this.isInitialized = true;
+ }
+
+ // Custom element removed from page.
+ disconnectedCallback() {
+ this.isInitialized = false;
+ }
+
+ // Initialize the textarea element, adding the KeyboardEvent listeners.
+ private initializeTextArea(): void {
+
+ const element = this.isNullOrEmpty(this.anchor)
+ ? null
+ : document.getElementById(this.anchor!);
+
+ if (element === null) {
+ throw new Error(`Impossible to find a textarea or a textinput element, with the id: '${this.anchor}'.`);
+ }
+
+ this.textArea = this.isNotNullOrEmpty(this.shadowQuerySelector)
+ ? this.textArea = element?.shadowRoot?.querySelector(this.shadowQuerySelector!) as HTMLTextAreaElement | HTMLInputElement
+ : element as HTMLTextAreaElement | HTMLInputElement;
+
+ if (this.textArea === null) {
+ throw new Error(`Impossible to find a textarea or a textinput element, with the id: '${this.anchor}'.`);
+ }
+
+ //this.suggestionDisplay = this.shouldUseInlineSuggestions(this.textArea)
+ // ? new InlineSuggestionDisplay(this, this.textArea)
+ // : new OverlaySuggestionDisplay(this, this.textArea);
+
+ this.suggestionDisplay = new InlineSuggestionDisplay(this, this.textArea);
+
+ this.textArea.spellcheck = false;
+ this.textArea.addEventListener('keydown', e => this.handleKeyDown(e as KeyboardEvent));
+ this.textArea.addEventListener('keyup', e => this.handleKeyUp(e as KeyboardEvent));
+ this.textArea.addEventListener('mousedown', () => this.removeExistingOrPendingSuggestion());
+ this.textArea.addEventListener('focusout', () => this.removeExistingOrPendingSuggestion());
+
+ // If you scroll, we don't need to kill any pending suggestion request, but we do need to hide
+ // any suggestion that's already visible because the fake cursor will now be in the wrong place
+ this.textArea.addEventListener('scroll', () => this.suggestionDisplay.reject(), { passive: true });
+ }
+
+ // Handle the keydown event.
+ private handleKeyDown(event: KeyboardEvent): void {
+
+ switch (event.key) {
+ case 'Tab':
+ if (this.suggestionDisplay.isShowing()) {
+ this.suggestionDisplay.accept();
+ this.value = null;
+ event.preventDefault();
+ }
+ break;
+
+ case 'Alt':
+ case 'Control':
+ case 'Shift':
+ case 'Command':
+ this.removeExistingOrPendingSuggestion();
+ break;
+
+ default:
+ const keyMatchesExistingSuggestion = this.suggestionDisplay.isShowing()
+ && this.suggestionDisplay.currentSuggestion.startsWith(event.key);
+
+ if (keyMatchesExistingSuggestion) {
+ // Let the typing happen, but without side-effects like removing the existing selection
+ insertTextAtCaretPosition(this.textArea, event.key);
+ event.preventDefault();
+
+ // Update the existing suggestion to match the new text
+ this.suggestionDisplay.show(this.suggestionDisplay.currentSuggestion.substring(event.key.length));
+ scrollTextAreaDownToCaretIfNeeded(this.textArea);
+
+ } else {
+ this.removeExistingOrPendingSuggestion();
+ }
+ break;
+ }
+ }
+
+ // If this was changed to a 'keypress' event instead, we'd only initiate suggestions after
+ // the user types a visible character, not pressing another key (e.g., arrows, or ctrl+c).
+ // However for now I think it is desirable to show suggestions after cursor movement.
+ private handleKeyUp(event: KeyboardEvent) {
+ // If a suggestion is already visible, it must match the current keystroke or it would
+ // already have been removed during keydown. So we only start the timeout process if
+ // there's no visible suggestion.
+ if (!this.suggestionDisplay.isShowing()) {
+ clearTimeout(this.typingDebounceTimeout ?? undefined);
+ this.typingDebounceTimeout = setTimeout(() => this.handleTypingPaused(), this.delay ?? 350);
+ }
+ }
+
+ // If the user has paused typing, we should show a suggestion.
+ private handleTypingPaused() {
+ if (this.getActiveElement() !== this.textArea) {
+ return;
+ }
+
+ // We only show a suggestion if the cursor is at the end of the current line. Inserting suggestions in
+ // the middle of a line is confusing (things move around in unusual ways).
+ // TODO: You could also allow the case where all remaining text on the current line is whitespace
+ const isAtEndOfCurrentLine =
+ this.textArea.selectionStart === this.textArea.selectionEnd
+ && (this.textArea.selectionStart === this.textArea.value.length ||
+ this.textArea.value[this.textArea.selectionStart ?? 0] === '\n');
+
+ if (!isAtEndOfCurrentLine) {
+ return;
+ }
+
+ this.showSuggestion(this.value);
+ }
+
+ // Remove any existing suggestion.
+ private removeExistingOrPendingSuggestion() {
+ clearTimeout(this.typingDebounceTimeout ?? undefined);
+
+ this.pendingSuggestionAbortController?.abort();
+ this.pendingSuggestionAbortController = undefined;
+
+ this.suggestionDisplay.reject();
+ }
+
+ // Show the suggestion, included in the `value` attribute.
+ private showSuggestion(suggestionText: string | null): void {
+
+ // Cancel any pending suggestion
+ this.suggestionDisplay.reject();
+ this.pendingSuggestionAbortController?.abort();
+ this.pendingSuggestionAbortController = new AbortController();
+
+ // If the text is too short, don't show a suggestion
+ if (this.textArea.value.length < (this.minlength ?? 0) || this.isNullOrEmpty(suggestionText)) {
+ return;
+ }
+
+ // Show the suggestion
+ const snapshot = {
+ abortSignal: this.pendingSuggestionAbortController.signal,
+ textAreaValue: this.textArea.value,
+ cursorPosition: this.textArea.selectionStart,
+ };
+
+ if (suggestionText !== null && this.isNotNullOrEmpty(suggestionText)
+ && snapshot.textAreaValue === this.textArea.value
+ && snapshot.cursorPosition === this.textArea.selectionStart) {
+ if (!suggestionText.endsWith(' ')) {
+ suggestionText += ' ';
+ }
+
+ this.suggestionDisplay.show(suggestionText);
+ }
+ }
+
+
+ /**
+ * Update the attribute value.
+ * @param name
+ * @param value
+ */
+ private updateAttribute(name: string, value: string | number | null): void {
+
+ if (this.getAttribute(name) != value) {
+ if (value) {
+ this.setAttribute(name, '' + value);
+ } else {
+ this.removeAttribute(name);
+ }
+ }
+
+ // value attribute changed
+ if (name === "value" && typeof value === 'string' && this.isNotNullOrEmpty(value) && this.isNotNullOrEmpty(this.textArea.value) && this.suggestionDisplay.isShowing()) {
+ this.showSuggestion(value);
+ }
+
+ }
+
+ /**
+ * Determines if the inline suggestions should be used.
+ * @param textArea
+ * @returns
+ */
+ private shouldUseInlineSuggestions(textArea: HTMLTextAreaElement): boolean {
+ // Allow the developer to specify this explicitly if they want
+ const explicitConfig = textArea.getAttribute('data-inline-suggestions');
+ if (explicitConfig) {
+ return explicitConfig.toLowerCase() === 'true';
+ }
+
+ // ... but by default, we use overlay on touch devices, inline on non-touch devices
+ // That's because:
+ // - Mobile devices will be touch, and most mobile users don't have a "tab" key by which to accept inline suggestions
+ // - Mobile devices such as iOS will display all kinds of extra UI around selected text (e.g., selection handles),
+ // which would look completely wrong
+ // In general, the overlay approach is the risk-averse one that works everywhere, even though it's not as attractive.
+ const isTouch = 'ontouchstart' in window; // True for any mobile. Usually not true for desktop.
+ return !isTouch;
+ }
+
+ private isNullOrEmpty(value: string | null): boolean {
+ return !this.isNotNullOrEmpty(value);
+ }
+
+ private isNotNullOrEmpty(value: string | null): boolean {
+ return value !== null && value !== undefined && value !== '';
+ }
+
+ private getActiveElement(): Element | null {
+
+ if (this.isNotNullOrEmpty(this.shadowQuerySelector)) {
+ return document.activeElement?.shadowRoot?.querySelector(this.shadowQuerySelector!) as HTMLTextAreaElement | HTMLInputElement;
+ }
+
+ return document.activeElement;
+ }
+}
diff --git a/src/Core.Scripts/src/Components/TextSuggestion/InlineSuggestionDisplay.ts b/src/Core.Scripts/src/Components/TextSuggestion/InlineSuggestionDisplay.ts
new file mode 100644
index 0000000000..e081c58787
--- /dev/null
+++ b/src/Core.Scripts/src/Components/TextSuggestion/InlineSuggestionDisplay.ts
@@ -0,0 +1,194 @@
+import { getCaretOffsetFromOffsetParent, scrollTextAreaDownToCaretIfNeeded } from "./CaretUtil";
+import { FluentTextSuggestion } from "./FluentTextSuggestion";
+import { SuggestionDisplay } from "./SuggestionDisplay";
+
+export class InlineSuggestionDisplay implements SuggestionDisplay {
+ private latestSuggestionText: string = '';
+ private suggestionStartPos: number | null = null;
+ private suggestionEndPos: number | null = null;
+ private fakeCaret: FakeCaret | null = null;
+ private originalValueProperty: PropertyDescriptor;
+
+ public static SUGGESTION_VISIBLE_ATTRIBUTE: string = 'fluent-suggestion-visible';
+
+ constructor(private owner: FluentTextSuggestion, private textArea: HTMLTextAreaElement | HTMLInputElement) {
+ // When any other JS code asks for the value of the textarea, we want to return the value
+ // without any pending suggestion, otherwise it will break things like bindings
+ this.originalValueProperty = findPropertyRecursive(textArea, 'value');
+ const self: any = this;
+ Object.defineProperty(textArea, 'value', {
+ get() {
+ const trueValue = self.originalValueProperty.get.call(textArea);
+ return self.isShowing()
+ ? trueValue.substring(0, self.suggestionStartPos) + trueValue.substring(self.suggestionEndPos)
+ : trueValue;
+ },
+ set(v) {
+ self.originalValueProperty.set.call(textArea, v);
+ }
+ });
+ }
+
+ get valueIncludingSuggestion() {
+ return (this as any).originalValueProperty.get.call(this.textArea);
+ }
+
+ set valueIncludingSuggestion(val: string) {
+ (this as any).originalValueProperty.set.call(this.textArea, val);
+ }
+
+ isShowing(): boolean {
+ return this.suggestionStartPos !== null;
+ }
+
+ show(suggestion: string): void {
+ this.latestSuggestionText = suggestion;
+ this.suggestionStartPos = this.textArea.selectionStart ?? 0;
+ this.suggestionEndPos = this.suggestionStartPos + suggestion.length;
+
+ this.textArea.setAttribute(InlineSuggestionDisplay.SUGGESTION_VISIBLE_ATTRIBUTE, '');
+ this.valueIncludingSuggestion = this.valueIncludingSuggestion.substring(0, this.suggestionStartPos) + suggestion + this.valueIncludingSuggestion.substring(this.suggestionStartPos);
+ this.textArea.setSelectionRange(this.suggestionStartPos, this.suggestionEndPos);
+
+ this.fakeCaret ??= new FakeCaret(this.owner, this.textArea);
+ this.fakeCaret.show();
+ }
+
+ get currentSuggestion() {
+ return this.latestSuggestionText;
+ }
+
+ accept(): void {
+ this.textArea.setSelectionRange(this.suggestionEndPos, this.suggestionEndPos);
+ this.suggestionStartPos = null;
+ this.suggestionEndPos = null;
+ this.fakeCaret?.hide();
+ this.textArea.removeAttribute(InlineSuggestionDisplay.SUGGESTION_VISIBLE_ATTRIBUTE);
+
+ // The newly-inserted text could be so long that the new caret position is off the bottom of the textarea.
+ // It won't scroll to the new caret position by default
+ scrollTextAreaDownToCaretIfNeeded(this.textArea);
+ }
+
+ reject(): void {
+ if (!this.isShowing()) {
+ return; // No suggestion is shown
+ }
+
+ const prevSelectionStart = this.textArea.selectionStart;
+ const prevSelectionEnd = this.textArea.selectionEnd;
+ this.valueIncludingSuggestion = this.valueIncludingSuggestion.substring(0, (this as any).suggestionStartPos) + this.valueIncludingSuggestion.substring((this as any).suggestionEndPos);
+
+ if (this.suggestionStartPos === prevSelectionStart && this.suggestionEndPos === prevSelectionEnd) {
+ // For most interactions we don't need to do anything to preserve the cursor position, but for
+ // 'scroll' events we do (because the interaction isn't going to set a cursor position naturally)
+ this.textArea.setSelectionRange(prevSelectionStart, prevSelectionStart /* not 'end' because we removed the suggestion */);
+ }
+
+ this.suggestionStartPos = null;
+ this.suggestionEndPos = null;
+ this.textArea.removeAttribute(InlineSuggestionDisplay.SUGGESTION_VISIBLE_ATTRIBUTE);
+ this.fakeCaret?.hide();
+ }
+}
+
+class FakeCaret {
+ readonly caretDiv: HTMLDivElement;
+
+ constructor(private owner: FluentTextSuggestion, private textArea: HTMLTextAreaElement | HTMLInputElement) {
+ this.caretDiv = document.createElement('div');
+ owner.appendChild(this.caretDiv);
+
+ const caretStyle = document.createElement('style');
+ caretStyle.innerHTML = `
+ @keyframes caret-blink {
+ from, to {
+ opacity: 100%;
+ }
+ 50% {
+ opacity: 0%;
+ }
+ }
+
+ [${InlineSuggestionDisplay.SUGGESTION_VISIBLE_ATTRIBUTE}]::selection {
+ color: #999;
+ background-color: transparent;
+ }
+ `;
+ owner.appendChild(caretStyle);
+
+ const shadowRoot = findFirstShadowRoot(textArea);
+ if (shadowRoot !== null && shadowRoot instanceof ShadowRoot) {
+ const style = document.createElement('style');
+ style.textContent = `
+ input[${InlineSuggestionDisplay.SUGGESTION_VISIBLE_ATTRIBUTE}]::selection,
+ textarea[${InlineSuggestionDisplay.SUGGESTION_VISIBLE_ATTRIBUTE}]::selection {
+ color: #999;
+ background-color: transparent;
+ }
+ `;
+ shadowRoot.appendChild(style);
+ }
+ }
+
+ addExtraTop(): number {
+ // This is a hack to make the caret appear in the right place in the FluentTextInput.
+ // TODO: how to find these 6px by code?
+ if (this.owner.shadowQuerySelector !== null && this.owner.shadowQuerySelector.includes("input")) {
+ return 6;
+ }
+
+ return 0;
+ }
+
+ addExtraLeft(): number {
+ return 0;
+ }
+
+ show() {
+ const caretOffset = getCaretOffsetFromOffsetParent(this.textArea);
+ const style = this.caretDiv.style;
+ style.position = 'absolute';
+ style.display = 'block';
+ style.top = (caretOffset.top + this.addExtraTop()) + 'px';
+ style.left = (caretOffset.left + this.addExtraLeft()) + 'px';
+ style.height = caretOffset.height + 'px';
+ style.width = '1.0px';
+ style.zIndex = this.textArea.style.zIndex;
+ style.backgroundColor = caretOffset.elemStyle.caretColor;
+ style.animation = 'caret-blink 1.025s step-end infinite';
+ }
+
+ hide() {
+ this.caretDiv.style.display = 'none';
+ }
+}
+
+function findPropertyRecursive(obj: any, propName: string): PropertyDescriptor {
+ while (obj) {
+ const descriptor = Object.getOwnPropertyDescriptor(obj, propName);
+ if (descriptor) {
+ return descriptor;
+ }
+ obj = Object.getPrototypeOf(obj);
+ }
+
+ throw new Error(`Property ${propName} not found on object or its prototype chain`);
+}
+
+function findFirstShadowRoot(element: Element) {
+ let currentNode: Node | null = element;
+
+ while (currentNode) {
+ // Check if the current node is a shadow root
+ const rootNode = currentNode.getRootNode();
+ if (rootNode instanceof ShadowRoot) {
+ return rootNode; // Return the first ShadowRoot found
+ }
+
+ // Move to the next parent node
+ currentNode = currentNode.parentNode;
+ }
+
+ return null; // No ShadowRoot found
+}
diff --git a/src/Core.Scripts/src/Components/TextSuggestion/SuggestionDisplay.ts b/src/Core.Scripts/src/Components/TextSuggestion/SuggestionDisplay.ts
new file mode 100644
index 0000000000..84fb1e3011
--- /dev/null
+++ b/src/Core.Scripts/src/Components/TextSuggestion/SuggestionDisplay.ts
@@ -0,0 +1,8 @@
+export interface SuggestionDisplay {
+ show(suggestion: string): void;
+ accept(): void;
+ reject(): void;
+ isShowing(): boolean;
+
+ get currentSuggestion(): string;
+}
diff --git a/src/Core.Scripts/src/Startup.ts b/src/Core.Scripts/src/Startup.ts
index 95748c4ca6..0be9cde78e 100644
--- a/src/Core.Scripts/src/Startup.ts
+++ b/src/Core.Scripts/src/Startup.ts
@@ -1,16 +1,16 @@
import { Microsoft as LoggerFile } from './Utilities/Logger';
import { Microsoft as FluentUIComponentsFile } from './FluentUIWebComponents';
-import { Microsoft as FluentPageScriptFile } from './Components/PageScript/FluentPageScript';
import { Microsoft as FluentUIStylesFile } from './FluentUIStyles';
import { Microsoft as FluentUICustomEventsFile } from './FluentUICustomEvents';
import { StartedMode } from './d-ts/StartedMode';
+import { FluentPageScript } from './Components/PageScript/FluentPageScript';
+import { FluentTextSuggestion } from './Components/TextSuggestion/FluentTextSuggestion';
export namespace Microsoft.FluentUI.Blazor.Startup {
// Alias
import Logger = LoggerFile.FluentUI.Blazor.Utilities.Logger;
import FluentUIComponents = FluentUIComponentsFile.FluentUI.Blazor.FluentUIWebComponents;
- import FluentPageScript = FluentPageScriptFile.FluentUI.Blazor.Components.PageScript;
import FluentUIStyles = FluentUIStylesFile.FluentUI.Blazor.FluentUIStyles;
import FluentUICustomEvents = FluentUICustomEventsFile.FluentUI.Blazor.FluentUICustomEvents;
@@ -46,6 +46,7 @@ export namespace Microsoft.FluentUI.Blazor.Startup {
// Initialize all custom components
FluentPageScript.registerComponent(blazor, mode);
+ FluentTextSuggestion.registerComponent(blazor, mode);
// [^^^ Add your other custom components before this line ^^^]
// Register all custom events