From ac08e38c1a61b108f98cb5086b009aafccd3c07a Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 10:31:34 +0100 Subject: [PATCH] :recycle: Use DS buttons and icons in the export assets modal (#12517) * :recycle: Use DS buttons and icons in the export assets modal The export modal close button becomes a ghost DS icon button and the progress toast close button an action one, which fits its 24px grid column; both are labelled "Close". Cancel and Export become DS buttons. The checkbox ticks and the toast icons use DS icons. The selection rows stay raw buttons, since they are list rows, and so does the link-styled retry/cancel button of the toast. The export frames e2e spec now finds the Export button by role, and a visual test covers the modal with all, some and no rows selected. AI-assisted-by: claude-opus-5-5 * :recycle: Make the export progress ring a button The export progress ring in the header opens the export details, but it was a div with a click handler. It is now a button named after the export status, with aria-expanded, and keeps its look. AI-assisted-by: claude-opus-5-5 * :recycle: Move export assets modal styles off the deprecated styles Spacing, sizes, borders, typographies and color aliases move to DS tokens and logical properties, and the button placeholders go away. The classes left without use are removed: the old modal buttons, the old close icon, and the file export entries, which live in files.scss. The refactor/ import stays only for the shared modal and checkbox placeholders, which move to the DS with the modals and the checkboxes. The checkbox placeholder shrinks a ticked icon to 12px, which pushed the DS tick off center: the tick keeps its 16px box now, and does not shrink inside the 14px flex box either. AI-assisted-by: claude-opus-5-5 --- .../playwright/ui/specs/export-frames.spec.js | 5 +- .../ui/visual-specs/workspace.spec.js | 55 +++ frontend/src/app/main/ui/exports/assets.cljs | 68 ++-- frontend/src/app/main/ui/exports/assets.scss | 366 ++++++++---------- 4 files changed, 246 insertions(+), 248 deletions(-) diff --git a/frontend/playwright/ui/specs/export-frames.spec.js b/frontend/playwright/ui/specs/export-frames.spec.js index fee390204d..aa34953a83 100644 --- a/frontend/playwright/ui/specs/export-frames.spec.js +++ b/frontend/playwright/ui/specs/export-frames.spec.js @@ -142,7 +142,10 @@ test.describe("Export frames to PDF", () => { await expect(page.getByText("1 of 2 elements selected")).toBeVisible(); // The export button should still be enabled - const exportButton = page.locator("input[type='button'][value='Export']"); + const exportButton = page.getByRole("button", { + name: "Export", + exact: true, + }); await expect(exportButton).toBeEnabled(); }); diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 807d1a45cd..48e715b175 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -908,3 +908,58 @@ test.describe("Grid cell options", () => { }); }); }); + +test.describe("Export modal", () => { + test("Shows the export frames modal with every selection state", async ({ + page, + }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC( + /get\-file\?/, + "workspace/get-file-export-frames.json", + ); + await workspace.mockRPC( + "get-file-object-thumbnails?file-id=*", + "workspace/get-file-object-thumbnails-blank.json", + ); + await workspace.mockRPC( + "get-file-fragment?file-id=*", + "workspace/get-file-fragment-blank.json", + ); + await workspace.goToWorkspace({ + fileId: "8d102c33-b98f-81f6-8006-fdd4ea9780d7", + pageId: "8d102c33-b98f-81f6-8006-fdd4ea9780d8", + }); + + await page.getByRole("button", { name: "Main menu" }).click(); + await page.getByText("file").last().click(); + await page.locator("#file-menu-export-frames").click(); + const modal = page.getByText("Export as PDF").locator("../.."); + await expect(page.getByText("2 of 2 elements selected")).toBeVisible(); + await page.mouse.move(0, 0); + // Every row checked. + await expect(modal).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await page.getByRole("button", { name: "Board 1" }).click(); + await expect(page.getByText("1 of 2 elements selected")).toBeVisible(); + await page.mouse.move(0, 0); + // Mixed: the header checkbox shows the "some selected" mark. + await expect(modal).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await page.getByRole("button", { name: "Board 2" }).click(); + await expect(page.getByText("0 of 2 elements selected")).toBeVisible(); + await page.mouse.move(0, 0); + // Nothing checked: the export button is disabled. + await expect(modal).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); diff --git a/frontend/src/app/main/ui/exports/assets.cljs b/frontend/src/app/main/ui/exports/assets.cljs index 83140cb47c..eb842c1f6b 100644 --- a/frontend/src/app/main/ui/exports/assets.cljs +++ b/frontend/src/app/main/ui/exports/assets.cljs @@ -16,7 +16,9 @@ [app.main.data.modal :as modal] [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.workspace.shapes :refer [shape-wrapper]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr c]] @@ -25,15 +27,6 @@ [cuerdas.core :as str] [rumext.v2 :as mf])) -(def ^:private neutral-icon - (deprecated-icon/icon-xref :msg-neutral (stl/css :icon))) - -(def ^:private error-icon - (deprecated-icon/icon-xref :delete-text (stl/css :icon))) - -(def ^:private close-icon - (deprecated-icon/icon-xref :close (stl/css :close-icon))) - (mf/defc export-multiple-dialog* {::mf/private true} [{:keys [exports title cmd no-selection origin name]}] @@ -83,9 +76,11 @@ [:div {:class (stl/css :modal-header)} [:h2 {:class (stl/css :modal-title)} title] - [:button {:class (stl/css :modal-close-btn) - :on-click cancel-fn} - deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :modal-close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click cancel-fn}]] [:* [:div {:class (stl/css :modal-content)} @@ -98,12 +93,12 @@ (cond all-checked? [:span {:class (stl/css-case :checkobox-tick true :global/checked true)} - deprecated-icon/tick] + [:> icon* {:icon-id i/tick :size "s"}]] all-unchecked? [:span {:class (stl/css-case :checkobox-tick true :global/uncheked true)}] :else [:span {:class (stl/css-case :checkobox-tick true :global/intermediate true)} - deprecated-icon/remove-icon])]] + [:> icon* {:icon-id i/remove :size "s"}]])]] [:div {:class (stl/css :selection-title)} (tr "dashboard.export-multiple.selected" (c (count enabled-exports)) @@ -122,7 +117,7 @@ (if (:enabled export) [:span {:class (stl/css-case :checkobox-tick true :global/checked true)} - deprecated-icon/tick] + [:> icon* {:icon-id i/tick :size "s"}]] [:span {:class (stl/css-case :checkobox-tick true :global/uncheked true)}])] @@ -158,19 +153,16 @@ (when (> (count all-exports) 0) [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} - [:input {:class (stl/css :cancel-button) - :type "button" - :value (tr "labels.cancel") - :on-click cancel-fn}] + [:> button* {:variant "secondary" + :on-click cancel-fn} + (tr "labels.cancel")] - [:input {:class (stl/css-case :accept-btn true - :btn-disabled (or in-progress? all-unchecked?)) - :disabled (or in-progress? all-unchecked?) - :type "button" - :value (if in-progress? - (tr "workspace.options.exporting-object") - (tr "labels.export")) - :on-click (when-not in-progress? accept-fn)}]]])]]])) + [:> button* {:variant "primary" + :disabled (or in-progress? all-unchecked?) + :on-click (when-not in-progress? accept-fn)} + (if in-progress? + (tr "workspace.options.exporting-object") + (tr "labels.export"))]]])]]])) (mf/defc shapes-no-selection [] [:div {:class (stl/css :no-selection)} @@ -291,8 +283,11 @@ [:* (when widget-visible? - [:div {:class (stl/css :export-progress-widget) - :on-click toggle-detail-visibility} + [:button {:type "button" + :class (stl/css :export-progress-widget) + :aria-label title + :aria-expanded (boolean detail-visible?) + :on-click toggle-detail-visibility} [:svg {:width "24" :height "24"} [:circle {:r "10" :cx "12" @@ -314,9 +309,8 @@ (when detail-visible? [:div {:class (stl/css-case :export-progress-modal true :has-error error?)} - (if error? - error-icon - neutral-icon) + [:> icon* {:icon-id (if error? i/delete-text i/msg-neutral) + :class (stl/css :icon)}] [:div {:class (stl/css :export-progress-title)} [:div {:class (stl/css :title-text)} title] @@ -342,9 +336,11 @@ [:span {:class (stl/css :progress)} (dm/str progress " / " total)])] - [:button {:class (stl/css :progress-close-button) - :on-click toggle-detail-visibility} - close-icon] + [:> icon-button* {:variant "action" + :tooltip-class (stl/css :progress-close-button) + :aria-label (tr "labels.close") + :icon i/close + :on-click toggle-detail-visibility}] (when-not error? [:svg {:class (stl/css :progress-bar) diff --git a/frontend/src/app/main/ui/exports/assets.scss b/frontend/src/app/main/ui/exports/assets.scss index 2780d53ea7..db3ec2d612 100644 --- a/frontend/src/app/main/ui/exports/assets.scss +++ b/frontend/src/app/main/ui/exports/assets.scss @@ -4,66 +4,75 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// Only for the shared modal and checkbox placeholders, which move to the DS +// with the modals and the checkboxes. @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/mixins.scss" as *; +@use "ds/typography.scss" as *; // PROGRESS WIDGET .export-progress-widget { - @include deprecated.flex-center; - - width: deprecated.$s-28; - height: deprecated.$s-28; + display: flex; + justify-content: center; + align-items: center; + inline-size: $sz-28; + block-size: $sz-28; + padding: 0; + border: none; + background: none; + cursor: pointer; } // PROGRESS MODAL .export-progress-modal { - --export-modal-bg-color: var(--alert-background-color-default); - --export-modal-fg-color: var(--alert-text-foreground-color-default); - --export-modal-icon-color: var(--alert-icon-foreground-color-default); - --export-modal-border-color: var(--alert-border-color-default); + --export-modal-bg-color: var(--color-background-primary); + --export-modal-fg-color: var(--color-foreground-primary); + --export-modal-icon-color: var(--color-foreground-primary); + --export-modal-border-color: var(--color-background-quaternary); position: absolute; - right: deprecated.$s-16; - top: deprecated.$s-48; + inset-inline-end: var(--sp-l); + inset-block-start: $sz-48; display: grid; - grid-template-columns: deprecated.$s-24 1fr deprecated.$s-24; + grid-template-columns: $sz-24 1fr $sz-24; grid-template-areas: "icon text close" "bar bar bar"; - gap: deprecated.$s-4 deprecated.$s-8; - padding-block-start: deprecated.$s-8; + gap: var(--sp-xs) var(--sp-s); + padding-block-start: var(--sp-s); background-color: var(--export-modal-bg-color); - border: deprecated.$s-1 solid var(--export-modal-border-color); - border-radius: deprecated.$br-8; - z-index: deprecated.$z-index-modal; + border: $b-1 solid var(--export-modal-border-color); + border-radius: $br-8; + z-index: var(--z-index-set); overflow: hidden; } .has-error { - --export-modal-bg-color: var(--alert-background-color-error); - --export-modal-fg-color: var(--alert-text-foreground-color-error); - --export-modal-icon-color: var(--alert-icon-foreground-color-error); - --export-modal-border-color: var(--alert-border-color-error); + --export-modal-bg-color: var(--color-background-error); + --export-modal-icon-color: var(--color-accent-error); + --export-modal-border-color: var(--color-accent-error); grid-template-areas: "icon text close"; - gap: deprecated.$s-8; - padding-block: deprecated.$s-8; + gap: var(--sp-s); + padding-block: var(--sp-s); } .icon { - @extend %button-icon; - grid-area: icon; align-self: center; - margin-inline-start: deprecated.$s-8; - stroke: var(--export-modal-icon-color); + margin-inline-start: var(--sp-s); + color: var(--export-modal-icon-color); } .export-progress-title { - @include deprecated.body-medium-typography; + @include use-typography("body-medium"); display: grid; grid-template-columns: auto 1fr; - gap: deprecated.$s-8; + gap: var(--sp-s); grid-area: text; align-self: center; padding: 0; @@ -72,130 +81,137 @@ } .progress { - @include deprecated.body-medium-typography; + @include use-typography("body-medium"); - padding-left: deprecated.$s-8; + padding-inline-start: var(--sp-s); margin: 0; align-self: center; - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); } +// Link-styled text button: stays a raw button. .retry-btn { - @include deprecated.button-style; - @include deprecated.body-small-typography; + @include use-typography("body-small"); display: inline; - text-align: left; - color: var(--modal-link-foreground-color); + text-align: start; + color: var(--color-accent-primary); margin: 0; padding: 0; + border: none; + background: none; + cursor: pointer; } .progress-close-button { - @include deprecated.button-style; - - padding: 0; - margin-inline-end: deprecated.$s-8; -} - -.close-icon { - @extend %button-icon; - - stroke: var(--export-modal-icon-color); + margin-inline-end: var(--sp-s); } .progress-bar { - margin-top: 0; + margin-block-start: 0; grid-area: bar; } // EXPORT MODAL .modal-overlay { @extend %modal-overlay-base; - - &.transparent { - background-color: transparent; - } } .modal-container { @extend %modal-container-base; - max-height: calc(10 * deprecated.$s-80); + max-block-size: px2rem(800); } .modal-header { - margin-bottom: deprecated.$s-24; + margin-block-end: var(--sp-xxl); } .modal-title { - @include deprecated.headline-medium-typography; + @include use-typography("headline-medium"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); } .modal-close-btn { - @extend %modal-close-btn-base; + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-xs); } .modal-content, .no-selection { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - margin-bottom: deprecated.$s-24; + margin-block-end: var(--sp-xxl); .modal-link { - @include deprecated.body-large-typography; + @include use-typography("body-large"); text-decoration: none; cursor: pointer; - color: var(--modal-link-foreground-color); + color: var(--color-accent-primary); } .selection-header { - @include deprecated.flex-row; - - height: deprecated.$s-32; - margin-bottom: deprecated.$s-4; + display: flex; + align-items: center; + gap: var(--sp-xs); + block-size: $sz-32; + margin-block-end: var(--sp-xs); .selection-btn { - @include deprecated.button-style; @extend %input-checkbox; - @include deprecated.flex-center; - height: deprecated.$s-24; - width: deprecated.$s-24; + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-24; + inline-size: $sz-24; padding: 0; - margin-left: deprecated.$s-16; + margin-inline-start: var(--sp-l); + border: none; + background: none; + cursor: pointer; span { @extend %checkbox-icon; + + // The DS icon draws its 12px glyph centered in a 16px box: keep that + // box, unshrunk, instead of the 12px the placeholder gives a legacy + // icon. + &:global(.checked) svg, + &:global(.intermediate) svg { + flex-shrink: 0; + inline-size: $sz-16; + block-size: $sz-16; + } } } .selection-title { - @include deprecated.body-large-typography; + @include use-typography("body-large"); - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); } } .selection-wrapper { position: relative; - width: 100%; - height: fit-content; + inline-size: 100%; + block-size: fit-content; } .selection-shadow { - width: 100%; - height: 100%; + inline-size: 100%; + block-size: 100%; &::after { position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 50px; + inset-block-end: 0; + inset-inline-start: 0; + inline-size: 100%; + block-size: px2rem(50); background: linear-gradient(to top, rgb(24 24 26 / 1) 0%, rgb(24 24 26 / 0) 100%); content: ""; pointer-events: none; @@ -203,84 +219,99 @@ } .selection-list { - @include deprecated.flex-column; - - max-height: deprecated.$s-400; + display: flex; + flex-direction: column; + gap: var(--sp-xs); + max-block-size: $sz-400; overflow-y: auto; - padding-bottom: deprecated.$s-12; + padding-block-end: var(--sp-m); .selection-row { - @include deprecated.flex-row; - - background-color: var(--entry-background-color); - min-height: deprecated.$s-40; - border-radius: deprecated.$br-8; + display: flex; + align-items: center; + gap: var(--sp-xs); + background-color: var(--color-background-tertiary); + min-block-size: $sz-40; + border-radius: $br-8; + // A list row, not a DS button: the whole row toggles its export. .selection-btn { - @include deprecated.button-style; - display: grid; grid-template-columns: min-content auto 1fr auto auto; align-items: center; - width: 100%; - height: 10%; - gap: deprecated.$s-8; - padding: 0 deprecated.$s-16; + inline-size: 100%; + block-size: 10%; + gap: var(--sp-s); + padding: 0 var(--sp-l); + border: none; + background: none; + cursor: pointer; .checkbox-wrapper { @extend %input-checkbox; - @include deprecated.flex-center; - height: deprecated.$s-24; - width: deprecated.$s-24; + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-24; + inline-size: $sz-24; padding: 0; .checkobox-tick { @extend %checkbox-icon; + + // The DS icon draws its 12px glyph centered in a 16px box: keep that + // box, unshrunk, instead of the 12px the placeholder gives a + // legacy icon. + &:global(.checked) svg, + &:global(.intermediate) svg { + flex-shrink: 0; + inline-size: $sz-16; + block-size: $sz-16; + } } } - .selection-name { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; + .selection-name, + .selection-scale, + .selection-extension { + @include use-typography("body-large"); + @include text-ellipsis; + color: var(--color-foreground-secondary); + } + + .selection-name { flex-grow: 1; - color: var(--modal-text-foreground-color); text-align: start; } .selection-scale { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; - - min-width: deprecated.$s-108; - padding: deprecated.$s-12; - color: var(--modal-text-foreground-color); + min-inline-size: px2rem(108); + padding: var(--sp-m); } .selection-extension { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; - - min-width: deprecated.$s-72; - padding: deprecated.$s-12; - color: var(--modal-text-foreground-color); + min-inline-size: px2rem(72); + padding: var(--sp-m); } } .image-wrapper { - @include deprecated.flex-center; - - min-height: deprecated.$s-32; - min-width: deprecated.$s-32; - background-color: var(--app-white); - border-radius: deprecated.$br-6; + display: flex; + justify-content: center; + align-items: center; + min-block-size: $sz-32; + min-inline-size: $sz-32; + background-color: var(--color-static-white); + border-radius: $br-6; margin: auto 0; + // The thumbnail markup has no class of its own. img, svg { object-fit: contain; - max-height: deprecated.$s-40; + max-block-size: $sz-40; } } } @@ -288,101 +319,14 @@ } .action-buttons { - @extend %modal-action-btns; -} - -.cancel-button { - @extend %modal-cancel-btn; -} - -.accept-btn { - @extend %modal-accept-btn; - - &.danger { - @extend %modal-danger-btn; - } + display: flex; + justify-content: flex-end; + gap: var(--sp-l); } .modal-scd-msg, -.modal-subtitle, .modal-msg { - @include deprecated.body-large-typography; + @include use-typography("body-large"); - color: var(--modal-text-foreground-color); -} - -.export-option { - @extend %input-checkbox; - - width: 100%; - align-items: flex-start; - - label { - align-items: flex-start; - - .modal-subtitle { - @include deprecated.body-large-typography; - - color: var(--modal-title-foreground-color); - } - } - - span { - margin-top: deprecated.$s-8; - } -} - -.option-content { - @include deprecated.flex-column; - @include deprecated.body-large-typography; -} - -.file-entry { - .file-name { - @include deprecated.flex-row; - - .file-icon { - @include deprecated.flex-center; - - height: deprecated.$s-16; - width: deprecated.$s-16; - - svg { - @extend %button-icon-small; - - stroke: var(--input-foreground); - } - } - - .file-name-label { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; - } - } - - &.loading { - .file-name { - color: var(--modal-text-foreground-color); - } - } - - &.error { - .file-name { - color: var(--modal-text-foreground-color); - - .file-icon svg { - stroke: var(--modal-text-foreground-color); - } - } - } - - &.success { - .file-name { - color: var(--modal-text-foreground-color); - - .file-icon svg { - stroke: var(--modal-text-foreground-color); - } - } - } + color: var(--color-foreground-secondary); }