UI – Refactor edit query > name and description fields to allow reasonable control of pencil icons (#17086)

## –> #16663 
- Display text within `textarea` only when editing. Since the
problematic pencil icons are hidden in this state, it is okay that their
position varies depending on browser (see previous discussions).
- When not editing, text and icon are displayed in a `button` , removing
the dependence of their position on the variable per browser`textarea`
"col"s.
- Note that the wrapping behavior of these texts can affect how much
space is placed after it _within_ its span/textarea – the distance of
the icon from this element remains constant.


https://www.loom.com/share/105df09a447e42cc9e3a71668f5d1d2c?sid=244d0543-cc4b-43ed-83dd-22959cb08879


<img width="1284" alt="Screenshot 2024-02-27 at 2 15 12 PM"
src="https://github.com/fleetdm/fleet/assets/61553566/7b8f7fea-bc57-4699-9d61-d93b19e8d922">



- [x] Changes file added for user-visible changes in `changes/`
- [x] Manual QA for all new/changed functionality

---------

Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
This commit is contained in:
Jacob Shandling 2024-02-29 21:56:31 -08:00 committed by GitHub
parent 30d05e072b
commit cbd1a142b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 146 additions and 98 deletions

View File

@ -0,0 +1,2 @@
- Fix a bug where the pencil icons next to the edit query name and description fields were
inconsistently spaced.

View File

@ -11,7 +11,7 @@ interface IAutoSizeInputFieldProps {
placeholder: string; placeholder: string;
value: string; value: string;
inputClassName?: string; inputClassName?: string;
maxLength: string; maxLength: number;
hasError?: boolean; hasError?: boolean;
isDisabled?: boolean; isDisabled?: boolean;
isFocused?: boolean; isFocused?: boolean;
@ -36,7 +36,7 @@ export default {
placeholder: "Type here...", placeholder: "Type here...",
type: "", type: "",
value: "", value: "",
maxLength: "250", maxLength: 250,
onFocus: noop, onFocus: noop,
onChange: noop, onChange: noop,
onKeyPress: noop, onKeyPress: noop,

View File

@ -12,12 +12,12 @@ interface IAutoSizeInputFieldProps {
placeholder: string; placeholder: string;
value: string; value: string;
inputClassName?: string; inputClassName?: string;
maxLength: string; maxLength: number;
hasError?: boolean; hasError?: boolean;
isDisabled?: boolean; isDisabled?: boolean;
isFocused?: boolean; isFocused?: boolean;
onFocus: () => void; onFocus?: () => void;
onBlur: () => void; onBlur?: () => void;
onChange: (newSelectedValue: string) => void; onChange: (newSelectedValue: string) => void;
onKeyPress: (event: KeyboardEvent<HTMLTextAreaElement>) => void; onKeyPress: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
} }
@ -33,8 +33,8 @@ const AutoSizeInputField = ({
hasError, hasError,
isDisabled, isDisabled,
isFocused, isFocused,
onFocus, onFocus = () => null,
onBlur, onBlur = () => null,
onChange, onChange,
onKeyPress, onKeyPress,
}: IAutoSizeInputFieldProps): JSX.Element => { }: IAutoSizeInputFieldProps): JSX.Element => {
@ -44,6 +44,7 @@ const AutoSizeInputField = ({
[`${baseClass}--disabled`]: isDisabled, [`${baseClass}--disabled`]: isDisabled,
[`${baseClass}--error`]: hasError, [`${baseClass}--error`]: hasError,
[`${baseClass}__textarea`]: true, [`${baseClass}__textarea`]: true,
"no-value": !inputValue,
}); });
const inputElement = useRef<any>(null); const inputElement = useRef<any>(null);
@ -87,11 +88,10 @@ const AutoSizeInputField = ({
onChange={onInputChange} onChange={onInputChange}
placeholder={placeholder} placeholder={placeholder}
value={inputValue} value={inputValue}
maxLength={parseInt(maxLength, 10)} maxLength={maxLength}
className={inputClasses} className={inputClasses}
cols={value ? value.length : placeholder.length - 2} cols={value ? value.length : placeholder.length - 2}
rows={1} rows={1}
tabIndex={0}
onFocus={onInputFocus} onFocus={onInputFocus}
onBlur={onInputBlur} onBlur={onInputBlur}
onKeyPress={onInputKeyPress} onKeyPress={onInputKeyPress}

View File

@ -3,7 +3,7 @@
color: $core-fleet-black; color: $core-fleet-black;
&::placeholder { &::placeholder {
color: $ui-fleet-black-50; @include placeholder;
} }
&:focus { &:focus {
@ -20,7 +20,9 @@
&::after, &::after,
input, input,
textarea { textarea {
white-space: pre-wrap;
width: auto; width: auto;
max-width: 100%;
grid-area: 1 / 2; grid-area: 1 / 2;
resize: none; resize: none;
background: none; background: none;

View File

@ -327,7 +327,7 @@ const PolicyForm = ({
value={lastEditedQueryName} value={lastEditedQueryName}
hasError={errors && errors.name} hasError={errors && errors.name}
inputClassName={`${baseClass}__policy-name`} inputClassName={`${baseClass}__policy-name`}
maxLength="160" maxLength={160}
onChange={setLastEditedQueryName} onChange={setLastEditedQueryName}
onFocus={() => setIsEditingName(true)} onFocus={() => setIsEditingName(true)}
onBlur={() => setIsEditingName(false)} onBlur={() => setIsEditingName(false)}
@ -368,7 +368,7 @@ const PolicyForm = ({
placeholder="Add description here." placeholder="Add description here."
value={lastEditedQueryDescription} value={lastEditedQueryDescription}
inputClassName={`${baseClass}__policy-description`} inputClassName={`${baseClass}__policy-description`}
maxLength="250" maxLength={250}
onChange={setLastEditedQueryDescription} onChange={setLastEditedQueryDescription}
onFocus={() => setIsEditingDescription(true)} onFocus={() => setIsEditingDescription(true)}
onBlur={() => setIsEditingDescription(false)} onBlur={() => setIsEditingDescription(false)}
@ -404,7 +404,7 @@ const PolicyForm = ({
placeholder="Add resolution here." placeholder="Add resolution here."
value={lastEditedQueryResolution} value={lastEditedQueryResolution}
inputClassName={`${baseClass}__policy-resolution`} inputClassName={`${baseClass}__policy-resolution`}
maxLength="500" maxLength={500}
onChange={setLastEditedQueryResolution} onChange={setLastEditedQueryResolution}
onFocus={() => setIsEditingResolution(true)} onFocus={() => setIsEditingResolution(true)}
onBlur={() => setIsEditingResolution(false)} onBlur={() => setIsEditingResolution(false)}

View File

@ -470,79 +470,103 @@ const EditQueryForm = ({
return platformCompatibility.render(); return platformCompatibility.render();
}; };
const queryNameClasses = classnames("query-name-wrapper", { const editName = () => {
[`${baseClass}--editing`]: isEditingName, if (!isEditingName) {
}); setIsEditingName(true);
}
};
const queryDescriptionClasses = classnames("query-description-wrapper", { const queryNameWrapperClasses = classnames("query-name-wrapper", {
[`${baseClass}--editing`]: isEditingDescription, "query-name-wrapper__editing": isEditingName,
}); });
const renderName = () => { const renderName = () => {
if (savedQueryMode) { if (savedQueryMode) {
return ( return (
<div className={queryNameWrapperClasses}>
{isEditingName ? (
<> <>
<div className={queryNameClasses}>
<AutoSizeInputField <AutoSizeInputField
name="query-name" name="query-name"
placeholder="Add name here" placeholder="Add name here"
value={lastEditedQueryName} value={lastEditedQueryName}
inputClassName={`${baseClass}__query-name`} inputClassName={`${baseClass}__query-name`}
maxLength="160" maxLength={160}
hasError={errors && errors.name} hasError={errors && errors.name}
onChange={setLastEditedQueryName} onChange={setLastEditedQueryName}
onFocus={() => setIsEditingName(true)}
onBlur={() => setIsEditingName(false)}
onKeyPress={onInputKeypress} onKeyPress={onInputKeypress}
isFocused={isEditingName} isFocused={isEditingName}
onBlur={() => {
setIsEditingName(false);
}}
/> />
<Button {/* yes, necessary in both places */}
variant="text-icon"
className="edit-link"
onClick={() => setIsEditingName(true)}
>
<Icon <Icon
name="pencil" name="pencil"
className={`edit-icon ${isEditingName ? "hide" : ""}`} className="edit-icon hide"
size="small-medium"
/> />
</Button>
</div>
</> </>
) : (
<button onClick={editName} onFocus={editName}>
<div className={`${baseClass}__query-name`}>
{lastEditedQueryName || (
<div className="placeholder">Add name here</div>
)}
</div>
{/* yes, necessary in both places */}
<Icon name="pencil" className="edit-icon" size="small-medium" />
</button>
)}
</div>
); );
} }
return <h1 className={`${baseClass}__query-name no-hover`}>New query</h1>; return <h1 className={`${baseClass}__query-name no-hover`}>New query</h1>;
}; };
const editDescription = () => {
if (!isEditingDescription) {
setIsEditingDescription(true);
}
};
const renderDescription = () => { const renderDescription = () => {
if (savedQueryMode) { if (savedQueryMode) {
return ( return (
<div className="query-description-wrapper">
{isEditingDescription ? (
<> <>
<div className={queryDescriptionClasses}>
<AutoSizeInputField <AutoSizeInputField
name="query-description" name="query-description"
placeholder="Add description here." placeholder="Add description here"
value={lastEditedQueryDescription} value={lastEditedQueryDescription}
maxLength="250"
inputClassName={`${baseClass}__query-description`} inputClassName={`${baseClass}__query-description`}
maxLength={250}
onChange={setLastEditedQueryDescription} onChange={setLastEditedQueryDescription}
onFocus={() => setIsEditingDescription(true)}
onBlur={() => setIsEditingDescription(false)}
onKeyPress={onInputKeypress} onKeyPress={onInputKeypress}
isFocused={isEditingDescription} isFocused={isEditingDescription}
onBlur={() => setIsEditingDescription(false)}
/> />
<Button {/* yes, necessary in both places */}
variant="text-icon"
className="edit-link"
onClick={() => setIsEditingDescription(true)}
>
<Icon <Icon
name="pencil" name="pencil"
className={`edit-icon ${isEditingDescription ? "hide" : ""}`} className="edit-icon hide"
size="small-medium"
/> />
</Button>
</div>
</> </>
) : (
<button onClick={editDescription} onFocus={editDescription}>
<div className={`${baseClass}__query-description`}>
{lastEditedQueryDescription || (
<div className="placeholder">Add description here</div>
)}
</div>
{/* yes, necessary in both places */}
<Icon name="pencil" className="edit-icon" size="small-medium" />
</button>
)}
</div>
); );
} }
return null; return null;

View File

@ -9,6 +9,7 @@
&__title-bar { &__title-bar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 1.5rem;
.form-field { .form-field {
margin-bottom: 0px; margin-bottom: 0px;
@ -35,7 +36,6 @@
.edit-link { .edit-link {
margin: 0; // override margin intended for buttons being used as form-fields, which these are not margin: 0; // override margin intended for buttons being used as form-fields, which these are not
cursor: pointer; cursor: pointer;
padding-left: $pad-small;
height: 18px; height: 18px;
} }
@ -44,41 +44,50 @@
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
.query-name-wrapper { .query-name-wrapper,
.query-description-wrapper {
display: flex; display: flex;
align-items: center; align-items: baseline;
gap: 0.5rem;
&:not(.edit-query-form--editing) { outline: 0;
textarea:hover { &:hover:not(.focus-visible):not(.no-hover) {
cursor: pointer;
color: $core-vibrant-blue; color: $core-vibrant-blue;
cursor: pointer;
}
.hide {
opacity: 0;
}
.icon {
align-self: initial;
}
button {
all: unset;
display: flex;
// must match gap of wrappers
gap: 0.5rem;
align-items: baseline;
} }
} }
.query-name-wrapper {
width: fit-content;
.no-value {
min-width: 170px;
}
.edit-query-form__query-name, .edit-query-form__query-name,
.input-sizer::after { .input-sizer::after {
font-size: $large; font-size: $large;
}
.component__auto-size-input-field {
letter-spacing: -0.5px; letter-spacing: -0.5px;
line-height: 2.3rem; line-height: 2.3rem;
} }
// compensate for FF weirdness with textarea line-height calculations
@-moz-document url-prefix() {
line-height: 2.25rem;
&__editing {
line-height: 2.3rem;
} }
.query-description-wrapper {
display: flex;
&:not(.edit-query-form--editing) {
textarea:hover {
cursor: pointer;
color: $core-vibrant-blue;
}
}
}
.edit-icon {
width: 14px;
height: 14px;
opacity: 1;
transition: opacity 0.2s;
&.hide {
opacity: 0;
} }
} }
} }
@ -105,24 +114,29 @@
} }
} }
&__textarea-content-display {
display: flex;
align-items: center;
}
&__query-name, &__query-name,
&__query-description { &__query-description {
width: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
border: 0; border: 0;
resize: none; resize: none;
white-space: normal; // collapse not supported on Firefox, so pre-wrap for consistency across browsers
white-space: pre-wrap;
background-color: transparent; background-color: transparent;
overflow: hidden; overflow: hidden;
&:hover:not(.focus-visible):not(.no-hover) { text-align: left;
color: $core-vibrant-blue;
cursor: pointer;
}
&.focus-visible { &.focus-visible {
outline: 0; outline: 0;
cursor: text; cursor: text;
} }
.placeholder {
@include placeholder;
}
} }
&__query-name { &__query-name {

View File

@ -2,6 +2,7 @@ export type IconSizes = keyof typeof ICON_SIZES;
export const ICON_SIZES = { export const ICON_SIZES = {
small: "12", small: "12",
"small-medium": "14",
medium: "16", medium: "16",
large: "24", large: "24",
"extra-large": "48", "extra-large": "48",

View File

@ -220,3 +220,8 @@ $max-width: 2560px;
// compensate in layout for extra clickable area button height // compensate in layout for extra clickable area button height
margin: -8px 0; margin: -8px 0;
} }
@mixin placeholder {
color: $ui-fleet-black-50;
opacity: 0.75;
}