diff --git a/.claude/rules/fleet-frontend.md b/.claude/rules/fleet-frontend.md index 2bca64aebcb..e1f6776536c 100644 --- a/.claude/rules/fleet-frontend.md +++ b/.claude/rules/fleet-frontend.md @@ -103,6 +103,7 @@ Render software title names via `getDisplayedSoftwareName(name, display_name)` f - Use `classnames()` for conditional classes - Style files use underscore prefix: `_styles.scss` - Prefer `gap` over `margin` for spacing between sibling elements when the parent is `display: flex`/`grid`. Use the layout mixins from `frontend/styles/var/mixins.scss`: `vertical-card-layout`, `vertical-form-layout`, `vertical-modal-layout`, `vertical-page-layout`, `vertical-page-tab-panel-layout`, `vertical-data-set-layout` +- **Row hover-reveal actions** (a button/dropdown that only appears when the containing row is hovered): add `className="row-hover-button"` to the element. The fade + `:focus-visible` reveal is defined for `tr` in `frontend/components/TableContainer/_styles.scss` and for `.paginated-list__row` in `frontend/components/PaginatedList/_styles.scss` — don't hand-roll a local `opacity: 0` / `:hover { opacity: 1 }` per-consumer. Never fade only `.children-wrapper` — bordered/filled `Button` variants (e.g. `secondary`) leave an empty button frame behind. ## Forms Cap free-text inputs' `maxLength` to the backend column length (check `server/datastore/mysql/schema.sql`, don't guess) via `inputOptions={{ maxLength: NAME_MAX_LENGTH }}` on `InputField`, using a local constant. diff --git a/frontend/components/PaginatedList/_styles.scss b/frontend/components/PaginatedList/_styles.scss index 4fe5eca467f..10658c29e3f 100644 --- a/frontend/components/PaginatedList/_styles.scss +++ b/frontend/components/PaginatedList/_styles.scss @@ -71,6 +71,25 @@ min-width: 0; /* This is crucial for proper shrinking */ } + + // Fade the entire button (border + fill + children), not just its children, + // so bordered/filled variants like `secondary` don't leave an empty box behind. + .row-hover-button { + opacity: 0; + // Suppress pointer events while hidden so the invisible button can't + // intercept clicks meant for the row. Tab navigation still works — + // pointer-events only gates mouse/touch, not keyboard focus. + pointer-events: none; + transition: opacity 250ms; + + // Reveal on the button's own keyboard focus, not just row hover — + // otherwise tabbing to a hidden row-hover button never shows it. + &:focus-visible { + opacity: 1; + pointer-events: auto; + } + } + &:not(.paginated-list__row--disabled):hover { background: $ui-off-white; cursor: pointer; @@ -82,6 +101,11 @@ .policy-row__preview-button { visibility: visible; } + + .row-hover-button { + opacity: 1; + pointer-events: auto; + } } &:first-child { diff --git a/frontend/components/TableContainer/_styles.scss b/frontend/components/TableContainer/_styles.scss index 5bf60ede64b..2b3473189f5 100644 --- a/frontend/components/TableContainer/_styles.scss +++ b/frontend/components/TableContainer/_styles.scss @@ -258,6 +258,10 @@ tr { .row-hover-button { opacity: 0; + // Suppress pointer events while hidden so the invisible button can't + // intercept clicks meant for the row. Tab navigation still works — + // pointer-events only gates mouse/touch, not keyboard focus. + pointer-events: none; transition: 250ms; text-overflow: none; @@ -265,12 +269,16 @@ // otherwise tabbing directly to a hidden row-hover button never shows it. &:focus-visible { opacity: 1; + pointer-events: auto; } // React-select's dropdown opacity must be controlled at input level for keyboard nav // So must be controlled at input level here as well &.actions-dropdown { opacity: 1; + // React-select handles its own keyboard nav; keep it hit-testable + // so focus/blur bookkeeping continues to work even before row hover. + pointer-events: auto; .actions-dropdown-select__control { opacity: 0; @@ -285,6 +293,7 @@ &:focus-visible { .row-hover-button { opacity: 1; + pointer-events: auto; } .row-hover-button.actions-dropdown { .actions-dropdown-select__control { diff --git a/frontend/components/buttons/Button/_styles.scss b/frontend/components/buttons/Button/_styles.scss index 2f3b06292f1..00506c1f266 100644 --- a/frontend/components/buttons/Button/_styles.scss +++ b/frontend/components/buttons/Button/_styles.scss @@ -127,6 +127,11 @@ $base-class: "button"; opacity: 0; } + // Wraps children so the loading spinner can swap in without a layout shift. + // Do NOT hide-then-reveal buttons by fading `.children-wrapper` alone — + // bordered/filled variants (`secondary` etc.) will leave an empty button + // box behind. Fade the whole button, or use `.row-hover-button` + // (see PaginatedList / TableContainer styles). .children-wrapper { display: flex; flex-direction: row; diff --git a/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchPaginatedList/RunScriptBatchPaginatedList.tsx b/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchPaginatedList/RunScriptBatchPaginatedList.tsx index e52ab7bd785..b5948f53a48 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchPaginatedList/RunScriptBatchPaginatedList.tsx +++ b/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchPaginatedList/RunScriptBatchPaginatedList.tsx @@ -79,6 +79,7 @@ const RunScriptBatchPaginatedList = ({ <> {script.name}