From 7a8d6e110bbd3ecf0fc24c318ef163c22f7dd853 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20Toppant=C3=B3?= Date: Fri, 17 Jul 2026 13:24:39 +0200 Subject: [PATCH] feat(ui-text-input,ui-select): add forcePlaceholder prop to TextInput MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional forcePlaceholder prop (default false) to TextInput v2 that always renders the placeholder when the input is empty, regardless of the interaction type. Previously the placeholder only showed when interaction was 'enabled'. Wire the prop into Select v2's trigger TextInput so a Select can display its placeholder even when it is not in the enabled interaction state. Ref: INSTUI-5104 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ui-select/src/Select/v2/index.tsx | 251 +++++++++--------- .../ui-text-input/src/TextInput/v2/index.tsx | 8 +- .../ui-text-input/src/TextInput/v2/props.ts | 7 + 3 files changed, 140 insertions(+), 126 deletions(-) diff --git a/packages/ui-select/src/Select/v2/index.tsx b/packages/ui-select/src/Select/v2/index.tsx index 52fe7d8e10..61327e3390 100644 --- a/packages/ui-select/src/Select/v2/index.tsx +++ b/packages/ui-select/src/Select/v2/index.tsx @@ -111,18 +111,18 @@ const MemoedOption = memo( (prevProps, nextProps) => { return ( prevProps.selectOption.props.isHighlighted === - nextProps.selectOption.props.isHighlighted && + nextProps.selectOption.props.isHighlighted && prevProps.selectOption.props.isSelected === - nextProps.selectOption.props.isSelected && + nextProps.selectOption.props.isSelected && prevProps.selectOption.props.isDisabled === - nextProps.selectOption.props.isDisabled && + nextProps.selectOption.props.isDisabled && prevProps.selectOption.props.children === - nextProps.selectOption.props.children && + nextProps.selectOption.props.children && prevProps.selectOption.props.id === nextProps.selectOption.props.id && prevProps.selectOption.props.renderBeforeLabel === - nextProps.selectOption.props.renderBeforeLabel && + nextProps.selectOption.props.renderBeforeLabel && prevProps.selectOption.props.renderAfterLabel === - nextProps.selectOption.props.renderAfterLabel && + nextProps.selectOption.props.renderAfterLabel && prevProps.children === nextProps.children ) } @@ -334,59 +334,59 @@ class Select extends Component { return this.interaction === 'enabled' ? { - onRequestShowOptions: (event) => { - onRequestShowOptions?.(event) - const selectedOptionId = this.selectedOptionId + onRequestShowOptions: (event) => { + onRequestShowOptions?.(event) + const selectedOptionId = this.selectedOptionId - if (selectedOptionId && !Array.isArray(selectedOptionId)) { - // highlight selected option on show - this.highlightOption(event, selectedOptionId) - } - }, - onRequestHideOptions: (event) => { - onRequestHideOptions?.(event) - }, - onRequestHighlightOption: ( - event, - { id, direction }: { id?: string; direction?: number } - ) => { - if (!isShowingOptions) return - - const highlightedOptionId = this.highlightedOptionId - // if id exists, use that - let highlightId = this._optionIds.indexOf(id!) > -1 ? id : undefined - if (!highlightId) { - if (!highlightedOptionId) { - // nothing highlighted yet, highlight first option - highlightId = this._optionIds[0] - } else { - // find next id based on direction - const index = this._optionIds.indexOf(highlightedOptionId) - highlightId = - index > -1 ? this._optionIds[index + direction!] : undefined + if (selectedOptionId && !Array.isArray(selectedOptionId)) { + // highlight selected option on show + this.highlightOption(event, selectedOptionId) } - } - if (highlightId) { - // only highlight if id exists as a valid option - this.highlightOption(event, highlightId) - } - }, - onRequestHighlightFirstOption: (event) => { - this.highlightOption(event, this._optionIds[0]) - }, - onRequestHighlightLastOption: (event) => { - this.highlightOption( + }, + onRequestHideOptions: (event) => { + onRequestHideOptions?.(event) + }, + onRequestHighlightOption: ( event, - this._optionIds[this._optionIds.length - 1] - ) - }, - onRequestSelectOption: (event, { id }) => { - if (id && this._optionIds.indexOf(id) !== -1) { - // only select if id exists as a valid option - onRequestSelectOption?.(event, { id }) + { id, direction }: { id?: string; direction?: number } + ) => { + if (!isShowingOptions) return + + const highlightedOptionId = this.highlightedOptionId + // if id exists, use that + let highlightId = this._optionIds.indexOf(id!) > -1 ? id : undefined + if (!highlightId) { + if (!highlightedOptionId) { + // nothing highlighted yet, highlight first option + highlightId = this._optionIds[0] + } else { + // find next id based on direction + const index = this._optionIds.indexOf(highlightedOptionId) + highlightId = + index > -1 ? this._optionIds[index + direction!] : undefined + } + } + if (highlightId) { + // only highlight if id exists as a valid option + this.highlightOption(event, highlightId) + } + }, + onRequestHighlightFirstOption: (event) => { + this.highlightOption(event, this._optionIds[0]) + }, + onRequestHighlightLastOption: (event) => { + this.highlightOption( + event, + this._optionIds[this._optionIds.length - 1] + ) + }, + onRequestSelectOption: (event, { id }) => { + if (id && this._optionIds.indexOf(id) !== -1) { + // only select if id exists as a valid option + onRequestSelectOption?.(event, { id }) + } } } - } : {} } @@ -413,12 +413,12 @@ class Select extends Component { return typeof renderOptionLabel === 'function' && !renderOptionLabel?.prototype?.isReactComponent ? (renderOptionLabel as any).bind(null, { - id, - isDisabled, - isSelected, - isHighlighted, - children - }) + id, + isDisabled, + isSelected, + isHighlighted, + children + }) : (renderOptionLabel as React.ReactNode) } @@ -532,18 +532,18 @@ class Select extends Component { const viewProps: Partial = isShowingOptions ? { - display: 'block', - overflowY: 'auto', - maxHeight: - optionsMaxHeight || - this._optionHeight * visibleOptionsCount! - - // in Chrome, we need to prevent scrolling when the bottom area of last item is hovered - (utils.isChromium() ? this.SCROLL_TOLERANCE : 0), - maxWidth: optionsMaxWidth || this.width, - background: 'primary', - elementRef: (node: Element | null) => (this._listView = node), - borderRadius: 'inherit' - } + display: 'block', + overflowY: 'auto', + maxHeight: + optionsMaxHeight || + this._optionHeight * visibleOptionsCount! - + // in Chrome, we need to prevent scrolling when the bottom area of last item is hovered + (utils.isChromium() ? this.SCROLL_TOLERANCE : 0), + maxWidth: optionsMaxWidth || this.width, + background: 'primary', + elementRef: (node: Element | null) => (this._listView = node), + borderRadius: 'inherit' + } : { maxHeight: 0 } return ( @@ -553,30 +553,30 @@ class Select extends Component { > {isShowingOptions ? Children.map(children as SelectChildren, (child, index) => { - if (!child || !matchComponentTypes(child, [Group, Option])) { - return // ignore invalid children - } - if (matchComponentTypes(child, [Option])) { - lastWasGroup = false - return this.renderOption(child, { - getOptionProps, - getDisabledOptionProps - }) - } - if (matchComponentTypes(child, [Group])) { - const afterGroup = lastWasGroup - lastWasGroup = true - return this.renderGroup(child, { - getOptionProps, - getDisabledOptionProps, - // for rendering separators appropriately - isFirstChild: index === 0, - isLastChild: index === Children.count(children) - 1, - afterGroup - }) - } - return - }) + if (!child || !matchComponentTypes(child, [Group, Option])) { + return // ignore invalid children + } + if (matchComponentTypes(child, [Option])) { + lastWasGroup = false + return this.renderOption(child, { + getOptionProps, + getDisabledOptionProps + }) + } + if (matchComponentTypes(child, [Group])) { + const afterGroup = lastWasGroup + lastWasGroup = true + return this.renderGroup(child, { + getOptionProps, + getDisabledOptionProps, + // for rendering separators appropriately + isFirstChild: index === 0, + isLastChild: index === Children.count(children) - 1, + afterGroup + }) + } + return + }) : null} @@ -615,8 +615,8 @@ class Select extends Component { return position === 'before' ? option.props.renderBeforeLabel : option.props.renderAfterLabel - ? option.props.renderAfterLabel - : this.renderIcon() + ? option.props.renderAfterLabel + : this.renderIcon() } } } else { @@ -625,8 +625,8 @@ class Select extends Component { return position === 'before' ? child.props.renderBeforeLabel : child.props.renderAfterLabel - ? child.props.renderAfterLabel - : this.renderIcon() + ? child.props.renderAfterLabel + : this.renderIcon() } } } @@ -723,26 +723,26 @@ class Select extends Component { // popup buttons rather than comboboxes. const overrideProps: Partial = !isEditable ? { - // We need role="combobox" for the 'open list' button shortcut to work - // with desktop screenreaders. - // But desktop Safari with Voiceover does not support proper combobox - // handling, a 'button' role is set as a workaround. - // See https://bugs.webkit.org/show_bug.cgi?id=236881 - // Also on iOS Chrome with role='combobox' it announces unnecessarily - // that its 'read-only' and that this is a 'textfield', see INSTUI-4500 - role: - utils.isSafari() || + // We need role="combobox" for the 'open list' button shortcut to work + // with desktop screenreaders. + // But desktop Safari with Voiceover does not support proper combobox + // handling, a 'button' role is set as a workaround. + // See https://bugs.webkit.org/show_bug.cgi?id=236881 + // Also on iOS Chrome with role='combobox' it announces unnecessarily + // that its 'read-only' and that this is a 'textfield', see INSTUI-4500 + role: + utils.isSafari() || utils.isAndroidOrIOS() || (interaction === 'disabled' && utils.isChromium()) - ? 'button' - : 'combobox', - title: inputValue, - 'aria-autocomplete': undefined, - 'aria-readonly': true - } + ? 'button' + : 'combobox', + title: inputValue, + 'aria-autocomplete': undefined, + 'aria-readonly': true + } : interaction === 'disabled' && utils.isChromium() - ? { role: 'button' } - : {} + ? { role: 'button' } + : {} // backdoor to autocomplete attr to work around chrome autofill issues if (passthroughProps['autoComplete']) { @@ -789,8 +789,8 @@ class Select extends Component { typeof onInputChange === 'function' ? onInputChange : inputValue - ? () => { } - : undefined, + ? () => {} + : undefined, onFocus, onBlur: utils.createChainedFunction(onBlur, onRequestHideOptions), @@ -799,15 +799,16 @@ class Select extends Component { // suppressHydrationWarning is needed because `role` depends on the browser type return ( ({ - backgroundReadonlyColor: componentTheme.backgroundColor - }) - })} + themeOverride: (componentTheme) => ({ + backgroundReadonlyColor: componentTheme.backgroundColor + }) + })} /> ) } @@ -864,8 +865,8 @@ class Select extends Component { mountNode !== undefined ? mountNode : utils.isAndroidOrIOS() - ? this.ref - : undefined + ? this.ref + : undefined } positionTarget={this._inputContainer} isShowingContent={isShowingOptions} diff --git a/packages/ui-text-input/src/TextInput/v2/index.tsx b/packages/ui-text-input/src/TextInput/v2/index.tsx index 52a09ecd98..148729c05c 100644 --- a/packages/ui-text-input/src/TextInput/v2/index.tsx +++ b/packages/ui-text-input/src/TextInput/v2/index.tsx @@ -59,6 +59,7 @@ class TextInput extends Component { // Leave interaction default undefined so that `disabled` and `readOnly` can also be supplied interaction: undefined, isRequired: false, + forcePlaceholder: false, display: 'block', shouldNotWrap: false, size: 'medium', @@ -219,6 +220,7 @@ class TextInput extends Component { value, defaultValue, isRequired, + forcePlaceholder, onFocus, ...rest } = this.props @@ -238,7 +240,11 @@ class TextInput extends Component { css={this.props.styles?.textInput} defaultValue={defaultValue} value={value} - placeholder={interaction === 'enabled' ? placeholder : undefined} + placeholder={ + forcePlaceholder || interaction === 'enabled' + ? placeholder + : undefined + } ref={this.handleInputRef} type={type} id={this.id} diff --git a/packages/ui-text-input/src/TextInput/v2/props.ts b/packages/ui-text-input/src/TextInput/v2/props.ts index 1411149fb9..7fe38c56ad 100644 --- a/packages/ui-text-input/src/TextInput/v2/props.ts +++ b/packages/ui-text-input/src/TextInput/v2/props.ts @@ -74,6 +74,7 @@ type TextInputOwnProps = { * When "disabled", the input changes visibly to indicate that it cannot * receive user interactions. When "readonly" the input still cannot receive * user interactions but it keeps the same styles as if it were enabled. + * Note: placeholder is only displayed when interaction is enabled. You can force it with forcePlaceholder prop */ interaction?: InteractionType @@ -131,6 +132,11 @@ type TextInputOwnProps = { */ isRequired?: boolean + /** + * Always displays placeholder when textInput is empty, regardless of interaction type + */ + forcePlaceholder?: boolean + /** * provides a reference to the underlying html root element */ @@ -218,6 +224,7 @@ const allowedProps: AllowedPropKeys = [ 'shouldNotWrap', 'placeholder', 'isRequired', + 'forcePlaceholder', 'elementRef', 'inputRef', 'inputContainerRef',