Clear button styles (#49292)
**Related issue:** Resolves #49276 **New features** - Added new "Secondary" (bordered, off-white fill) and "Subdued" (borderless, low-emphasis) button variants to match the Figma spec, alongside the existing Primary style. - Allowed rows to be selected in Controls > OS updates. **Cleanup** - Once nothing referenced the old styles anymore, fully removed the old `text-icon`, `brand-inverse-icon`, `inverse-alert`, `inverse`, and `icon` button variants (type, styles, and Storybook entries) from the shared `Button` component. - Removed the `iconStroke` prop, which had become a no-op once the old variants it supported were gone. - Renamed `ActionsDropdown`'s variants (`button`/`brand-button`/`small-button`) to `subdued`/`primary`/`secondary` to match the same naming used everywhere else. - Replaced a one-off dropdown implementation on the Software title page with the shared `ActionsDropdown` component, instead of maintaining duplicate styling logic. - Changed the button name on Host details > Reports > Report details from "View data for all hosts" to "View report for all hosts" (to match the previous page's Actions drop-down options). # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually <img width="1475" height="241" alt="Screenshot 2026-07-21 at 06 35 49" src="https://github.com/user-attachments/assets/7cfbd444-7837-40e8-854e-bc5989d57d85" /> <img width="661" height="306" alt="Screenshot 2026-07-21 at 06 37 18" src="https://github.com/user-attachments/assets/5d0c4873-8179-4089-b115-7e8cd3a53b4d" /> <img width="1427" height="423" alt="Screenshot 2026-07-21 at 06 37 30" src="https://github.com/user-attachments/assets/a4850a60-f44a-4902-b45e-0094f23a52f8" /> <img width="1427" height="640" alt="Screenshot 2026-07-21 at 06 37 46" src="https://github.com/user-attachments/assets/738a4a7f-cd7d-4162-b659-6f649c32204d" /> <img width="1445" height="479" alt="Screenshot 2026-07-22 at 07 03 22" src="https://github.com/user-attachments/assets/4f672dc0-5c6d-4eb8-8465-ed5233fcd1b2" /> <img width="811" height="871" alt="Screenshot 2026-07-21 at 06 41 20" src="https://github.com/user-attachments/assets/5421c96e-2dab-492a-af26-be0e5a7791ca" />
This commit is contained in:
@@ -32,7 +32,7 @@ class ChangeEmailForm extends Component {
|
||||
/>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button type="submit">Submit</Button>
|
||||
<Button onClick={onCancel} variant="inverse">
|
||||
<Button onClick={onCancel} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -52,7 +52,7 @@ class ChangePasswordForm extends Component {
|
||||
/>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button type="submit">Change password</Button>
|
||||
<Button onClick={onCancel} variant="inverse">
|
||||
<Button onClick={onCancel} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -88,7 +88,7 @@ const LoginForm = ({
|
||||
<Button
|
||||
className={`${baseClass}__sso-btn`}
|
||||
type="button"
|
||||
variant="inverse"
|
||||
variant="secondary"
|
||||
onClick={handleSSOSignOn}
|
||||
tabIndex={0}
|
||||
>
|
||||
@@ -134,7 +134,7 @@ const LoginForm = ({
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setShowPendingEmail(false)}
|
||||
variant="inverse"
|
||||
variant="subdued"
|
||||
className="back-link"
|
||||
>
|
||||
<Icon name="chevron-left" color="ui-fleet-black-75" />
|
||||
|
||||
@@ -78,7 +78,7 @@ class UserSettingsForm extends Component {
|
||||
/>
|
||||
<InputField {...fields.position} label="Position" />
|
||||
<div className="button-wrap">
|
||||
<Button onClick={onCancel} variant="inverse">
|
||||
<Button onClick={onCancel} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Update</Button>
|
||||
|
||||
@@ -99,64 +99,4 @@ describe("DropdownWrapper Component", () => {
|
||||
|
||||
expect(screen.getByText(/no results found/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("doesn't render selected value when variant is button", async () => {
|
||||
const buttonText = "Click me";
|
||||
render(
|
||||
<DropdownWrapper
|
||||
options={sampleOptions}
|
||||
value="option1"
|
||||
onChange={mockOnChange}
|
||||
name="test-dropdown"
|
||||
label="Test Dropdown"
|
||||
placeholder={buttonText}
|
||||
variant="button"
|
||||
/>
|
||||
);
|
||||
|
||||
// Check if the button text is rendered
|
||||
expect(screen.getByText(buttonText)).toBeInTheDocument();
|
||||
|
||||
// Open the dropdown
|
||||
await userEvent.click(screen.getByText(buttonText));
|
||||
|
||||
// Select Option 2
|
||||
await userEvent.click(screen.getByText(/option 2/i));
|
||||
|
||||
// Check if the button text is still rendered and not replaced by the selected option
|
||||
expect(screen.getByText(buttonText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/option 2/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Regression test for #45853 — react-select tracks the last click as
|
||||
// selectValue and re-focuses it on reopen, leaving the previously-clicked
|
||||
// option visually highlighted. Button variant overrides this by forcing
|
||||
// value to null.
|
||||
test("button variant does not mark a previously-clicked option as selected on reopen", async () => {
|
||||
const buttonText = "Actions";
|
||||
render(
|
||||
<DropdownWrapper
|
||||
options={sampleOptions}
|
||||
onChange={mockOnChange}
|
||||
name="test-dropdown"
|
||||
placeholder={buttonText}
|
||||
variant="button"
|
||||
/>
|
||||
);
|
||||
|
||||
// Open, click Option 2, then reopen
|
||||
await userEvent.click(screen.getByText(buttonText));
|
||||
await userEvent.click(screen.getByText(/option 2/i));
|
||||
await userEvent.click(screen.getByText(buttonText));
|
||||
|
||||
// On reopen, no menu option should carry the selected state.
|
||||
// react-select adds `--is-selected` to the option matching `value`; the
|
||||
// fix forces value to null for button variant so this class never appears.
|
||||
const options = document.querySelectorAll(".react-select__option");
|
||||
expect(options.length).toBeGreaterThan(0);
|
||||
|
||||
options.forEach((option) =>
|
||||
expect(option.className).not.toMatch(/--is-selected/)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,7 +99,7 @@ export interface CustomOptionType {
|
||||
iconName?: IconNames;
|
||||
}
|
||||
|
||||
type DropdownWrapperVariant = "table-filter" | "button";
|
||||
type DropdownWrapperVariant = "table-filter";
|
||||
|
||||
export interface IDropdownWrapper {
|
||||
options: CustomOptionType[];
|
||||
@@ -118,8 +118,7 @@ export interface IDropdownWrapper {
|
||||
placeholder?: string;
|
||||
/** E.g. scroll to view dropdown menu in a scrollable parent container */
|
||||
onMenuOpen?: () => void;
|
||||
/** Table filter dropdowns have filter icon and height: 40px
|
||||
* Button dropdowns have hover/active state, padding, height matching actual buttons, and no selected option styling */
|
||||
/** Table filter dropdowns have filter icon and height: 40px */
|
||||
variant?: DropdownWrapperVariant;
|
||||
/** This makes the menu fit all text without wrapping,
|
||||
* aligning right to fit text on screen */
|
||||
@@ -141,19 +140,6 @@ const getOptionBackgroundColor = (
|
||||
return "transparent";
|
||||
};
|
||||
|
||||
const getOptionFontWeight = (
|
||||
state: OptionProps<CustomOptionType, false>,
|
||||
variant?: DropdownWrapperVariant
|
||||
) => {
|
||||
// For "button" dropdowns, selected options are not styled differently
|
||||
if (variant === "button") {
|
||||
return "normal";
|
||||
}
|
||||
|
||||
// For other variants, selected options are bold
|
||||
return state.isSelected ? "600" : "normal";
|
||||
};
|
||||
|
||||
/** generates the default custom styles for the dropdown component.
|
||||
* NOTE: we export this from DropdownWrapper components so that other more
|
||||
* customisable dropdown components can use this for consistency in styling */
|
||||
@@ -165,76 +151,20 @@ export const generateCustomDropdownStyles = (
|
||||
): StylesConfig<CustomOptionType, false> => {
|
||||
return {
|
||||
container: (provided) => {
|
||||
const buttonVariantContainer = {
|
||||
borderRadius: "6px",
|
||||
"&:active": {
|
||||
backgroundColor: COLORS["ui-fleet-black-5"],
|
||||
},
|
||||
height: "38px",
|
||||
};
|
||||
|
||||
return {
|
||||
...provided,
|
||||
width: "100%",
|
||||
height: "36px",
|
||||
...(variant === "button" && buttonVariantContainer),
|
||||
};
|
||||
},
|
||||
|
||||
control: (provided, state) => {
|
||||
if (variant === "button") {
|
||||
return {
|
||||
backgroundColor: "initial",
|
||||
borderColor: "none",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
width: "max-content",
|
||||
padding: PADDING["pad-small"],
|
||||
border: 0,
|
||||
borderRadius: "6px",
|
||||
boxShadow: "none",
|
||||
cursor: "pointer",
|
||||
".dropdown-wrapper__indicator path": {
|
||||
stroke: COLORS["ui-fleet-black-75"],
|
||||
},
|
||||
"&:hover": {
|
||||
backgroundColor: COLORS["ui-fleet-black-5"],
|
||||
boxShadow: "none",
|
||||
".dropdown-wrapper__placeholder": {
|
||||
color: COLORS["ui-fleet-black-75-over"],
|
||||
},
|
||||
".dropdown-wrapper__indicator path": {
|
||||
stroke: COLORS["ui-fleet-black-75-over"],
|
||||
},
|
||||
},
|
||||
// Note: state.isFocused is intentionally not used as a highlight
|
||||
// trigger here. react-select's internal input retains focus after
|
||||
// the menu closes (outside click, post-modal-close, etc.), so
|
||||
// styling on it would leave the trigger visually highlighted
|
||||
// indefinitely (#45853). Hover + menuIsOpen cover the meaningful
|
||||
// interactive states for an action button.
|
||||
...(state.menuIsOpen && {
|
||||
backgroundColor: COLORS["ui-fleet-black-5"],
|
||||
".dropdown-wrapper__placeholder": {
|
||||
color: COLORS["ui-fleet-black-75-down"],
|
||||
},
|
||||
".dropdown-wrapper__indicator path": {
|
||||
stroke: COLORS["ui-fleet-black-75-down"],
|
||||
},
|
||||
".dropdown-wrapper__indicator svg": {
|
||||
transform: "rotate(180deg)",
|
||||
transition: "transform 0.25s ease",
|
||||
},
|
||||
}),
|
||||
...(variant === "button" && { height: "22px" }),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...provided,
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
width: "100%",
|
||||
minHeight: "36px", // react-select-5 defaults control minHeight to 38px
|
||||
backgroundColor: COLORS["core-fleet-white"],
|
||||
paddingLeft: "8px", // TODO: Update to match styleguide of (16px) when updating rest of UI (8px)
|
||||
paddingRight: "8px",
|
||||
@@ -308,23 +238,10 @@ export const generateCustomDropdownStyles = (
|
||||
}),
|
||||
};
|
||||
},
|
||||
placeholder: (provided, state) => {
|
||||
const buttonVariantPlaceholder = {
|
||||
color: state.isFocused
|
||||
? COLORS["ui-fleet-black-75-over"]
|
||||
: COLORS["ui-fleet-black-75"],
|
||||
fontSize: "13px",
|
||||
fontWeight: "600",
|
||||
lineHeight: "normal",
|
||||
paddingLeft: 0,
|
||||
opacity: isDisabled ? 0.5 : 1,
|
||||
marginTop: variant === "button" ? "-1px" : "1px", // TODO: Figure out vertical centering to not need pixel fix
|
||||
};
|
||||
|
||||
placeholder: (provided) => {
|
||||
return {
|
||||
...provided,
|
||||
fontSize: "13px",
|
||||
...(variant === "button" && buttonVariantPlaceholder),
|
||||
};
|
||||
},
|
||||
input: (provided) => {
|
||||
@@ -383,7 +300,7 @@ export const generateCustomDropdownStyles = (
|
||||
...provided,
|
||||
padding: 0,
|
||||
display: "flex",
|
||||
gap: PADDING[variant === "button" ? "pad-xsmall" : "pad-small"],
|
||||
gap: PADDING["pad-small"],
|
||||
flexWrap: "nowrap", // This ensures the value is on a single line and truncated
|
||||
}),
|
||||
option: (provided, state) => ({
|
||||
@@ -392,7 +309,7 @@ export const generateCustomDropdownStyles = (
|
||||
fontSize: "13px",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: getOptionBackgroundColor(state),
|
||||
fontWeight: getOptionFontWeight(state, variant),
|
||||
fontWeight: state.isSelected ? "600" : "normal",
|
||||
color: COLORS["core-fleet-black"],
|
||||
"&:hover": {
|
||||
backgroundColor: state.isDisabled
|
||||
@@ -462,7 +379,6 @@ const DropdownWrapper = ({
|
||||
}: IDropdownWrapper) => {
|
||||
const wrapperClassNames = classnames(baseClass, className, {
|
||||
[`${baseClass}__table-filter`]: variant === "table-filter",
|
||||
[`${baseClass}__button`]: variant === "button",
|
||||
[`${wrapperClassname}`]: !!wrapperClassname,
|
||||
});
|
||||
|
||||
@@ -481,13 +397,6 @@ const DropdownWrapper = ({
|
||||
|
||||
// Ability to handle value of type string or CustomOptionType
|
||||
const getCurrentValue = () => {
|
||||
// Button variant is a fire-and-forget action menu (selection isn't displayed
|
||||
// via `controlShouldRenderValue`). Forcing null prevents react-select from
|
||||
// tracking the last click as the selected value and re-focusing it on reopen
|
||||
// (#45853).
|
||||
if (variant === "button") {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return options.find((option) => option.value === value) || null;
|
||||
}
|
||||
@@ -565,7 +474,6 @@ const DropdownWrapper = ({
|
||||
tabIndex={isDisabled ? -1 : 0} // Ensures disabled dropdown has no keyboard accessibility
|
||||
placeholder={placeholder}
|
||||
onMenuOpen={onMenuOpen}
|
||||
controlShouldRenderValue={variant !== "button"} // Control doesn't change placeholder to selected value
|
||||
// Resolve accessible name: explicit prop wins, otherwise fall back
|
||||
// to the placeholder (usually "Select X"), otherwise the required
|
||||
// `name` (often a kebab-case identifier — least readable but
|
||||
|
||||
@@ -20,14 +20,4 @@
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
// Button-variant: background while focused so keyboard tabbing is
|
||||
// visible. Mirrors the menuIsOpen background applied via emotion in
|
||||
// DropdownWrapper.tsx. Inputs always match :focus-visible, so this
|
||||
// also applies on click — that's fine since it matches the open state.
|
||||
&__button {
|
||||
.react-select__control:has(input:focus-visible) {
|
||||
background-color: $ui-fleet-black-5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import classnames from "classnames";
|
||||
import { PlacesType } from "react-tooltip-5";
|
||||
|
||||
import FormField from "components/forms/FormField";
|
||||
import Button from "components/buttons/Button";
|
||||
import CopyButton from "components/buttons/CopyButton";
|
||||
import Icon from "components/Icon";
|
||||
|
||||
@@ -122,13 +123,13 @@ const InputField = ({
|
||||
|
||||
const copyText = typeof value === "string" ? value : String(value ?? "");
|
||||
|
||||
// Old-style icon copy button for textarea (positioned absolutely above textarea)
|
||||
// Copy button for textarea (positioned absolutely above textarea)
|
||||
const renderTextareaCopyButton = () => {
|
||||
return (
|
||||
<div
|
||||
className={`${baseClass}__copy-wrapper ${baseClass}__copy-wrapper--text-area`}
|
||||
>
|
||||
<CopyButton copyText={copyText} size="small" />
|
||||
<CopyButton copyText={copyText} variant="subdued" size="small" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -140,20 +141,21 @@ const InputField = ({
|
||||
{enableCopy && (
|
||||
<CopyButton
|
||||
copyText={copyText}
|
||||
variant="secondary"
|
||||
className={`${baseClass}__action-button`}
|
||||
tooltipOffset={10}
|
||||
/>
|
||||
)}
|
||||
{enableShowSecret && (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="secondary"
|
||||
className={`${baseClass}__action-button`}
|
||||
onClick={onToggleSecret}
|
||||
aria-label={showSecret ? "Hide secret" : "Show secret"}
|
||||
aria-pressed={showSecret}
|
||||
ariaLabel={showSecret ? "Hide secret" : "Show secret"}
|
||||
ariaPressed={showSecret}
|
||||
>
|
||||
<Icon name="eye" />
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
&__input-container--has-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $pad-xsmall;
|
||||
gap: $pad-small;
|
||||
|
||||
.input-field {
|
||||
flex: 1;
|
||||
@@ -119,13 +119,11 @@
|
||||
|
||||
&__action-buttons {
|
||||
display: flex;
|
||||
gap: $pad-xsmall;
|
||||
gap: $pad-small;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__action-button {
|
||||
@include bordered-icon-button;
|
||||
|
||||
.fleeticon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
@@ -157,7 +157,7 @@ const InputFieldWithIcon = ({
|
||||
{clearButton && !!value && (
|
||||
<Button
|
||||
onClick={() => handleClear()}
|
||||
variant="icon"
|
||||
variant="subdued"
|
||||
className={`${baseClass}__clear-button`}
|
||||
>
|
||||
<Icon name="close-filled" color="core-fleet-black" />
|
||||
|
||||
@@ -236,7 +236,7 @@ class SelectTargetsDropdown extends Component {
|
||||
type="button"
|
||||
className="target-select__clear"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
variant="inverse"
|
||||
variant="subdued"
|
||||
>
|
||||
<Icon name="close" />
|
||||
</Button>
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ const TargetOption = ({
|
||||
<Button
|
||||
className={`${baseClass}__add-btn`}
|
||||
onClick={handleSelect}
|
||||
variant="icon"
|
||||
variant="subdued"
|
||||
size="small"
|
||||
>
|
||||
<Icon name="plus" color="core-fleet-green" />
|
||||
|
||||
@@ -138,7 +138,7 @@ const EditPackForm = ({
|
||||
isLoadingPackQueries={isLoadingPackQueries}
|
||||
/>
|
||||
<div className={`${baseClass}__pack-buttons`}>
|
||||
<Button onClick={onCancelEditPack} type="button" variant="inverse">
|
||||
<Button onClick={onCancelEditPack} type="button" variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user