🎉 Reorder properties for a component (#7429)

* 🎉 Reorder properties when a component with variants is selected

* 🎉 Reorder properties when a single variant is selected

* ♻️ Refactor SCSS and component structure

* 📚 Update changelog

* 📎 PR changes (styling)

* 📎 PR changes (functionality)
This commit is contained in:
Luis de Dios 2025-10-08 11:27:01 +02:00 committed by GitHub
parent 4937580585
commit 544bedf7c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1079 additions and 986 deletions

View File

@ -37,6 +37,7 @@
- Switch several variant copies at the same time [Taiga #11411](https://tree.taiga.io/project/penpot/us/11411)
- Invitations management improvements [Taiga #3479](https://tree.taiga.io/project/penpot/us/3479)
- Alternative ways of creating variants - Button Viewport [Taiga #11931](https://tree.taiga.io/project/penpot/us/11931)
- Reorder properties for a component [Taiga #10225](https://tree.taiga.io/project/penpot/us/10225)
### :bug: Bugs fixed

View File

@ -81,6 +81,26 @@
#(assoc % :variant-error value))))))
(defn generate-reorder-variant-poperties
[changes variant-id from-pos to-space-between-pos]
(let [data (pcb/get-library-data changes)
objects (pcb/get-objects changes)
related-components (cfv/find-variant-components data objects variant-id)]
(reduce (fn [changes component]
(let [props (:variant-properties component)
props (ctv/reorder-by-moving-to-position props from-pos to-space-between-pos)
main-id (:main-instance-id component)
name (ctv/properties-to-name props)]
(-> changes
(pcb/update-component (:id component)
#(assoc % :variant-properties props)
{:apply-changes-local-library? true})
(pcb/update-shapes [main-id]
#(assoc % :variant-name name)))))
changes
related-components)))
(defn generate-add-new-property
[changes variant-id & {:keys [fill-values? editing? property-name property-value]}]
(let [data (pcb/get-library-data changes)

View File

@ -310,3 +310,27 @@
the real name of the shape joined by the properties values separated by '/'"
[variant]
(cpn/merge-path-item (:name variant) (str/replace (:variant-name variant) #", " " / ")))
(defn reorder-by-moving-to-position
"Reorder a vector by moving one of their items from some position to some space between positions.
It clamps the position numbers to a valid range."
[props from-pos to-space-between-pos]
(let [max-space-pos (count props)
max-prop-pos (dec max-space-pos)
from-pos (max 0 (min max-prop-pos from-pos))
to-space-between-pos (max 0 (min max-space-pos to-space-between-pos))]
(if (= from-pos to-space-between-pos)
props
(let [elem (nth props from-pos)
without-elem (-> []
(into (subvec props 0 from-pos))
(into (subvec props (inc from-pos))))
insert-pos (if (< from-pos to-space-between-pos)
(dec to-space-between-pos)
to-space-between-pos)]
(-> []
(into (subvec without-elem 0 insert-pos))
(into [elem])
(into (subvec without-elem insert-pos)))))))

View File

@ -159,3 +159,48 @@
(t/testing "update-number-in-repeated-prop-names"
(t/is (= (ctv/update-number-in-repeated-prop-names props) numbered-props)))))
(t/deftest reorder-by-moving-to-position
(let [props [{:name "border" :value "no"}
{:name "color" :value "blue"}
{:name "shadow" :value "yes"}
{:name "background" :value "none"}]]
(t/testing "reorder-by-moving-to-position"
(t/is (= (ctv/reorder-by-moving-to-position props 0 2) [{:name "color" :value "blue"}
{:name "border" :value "no"}
{:name "shadow" :value "yes"}
{:name "background" :value "none"}]))
(t/is (= (ctv/reorder-by-moving-to-position props 0 3) [{:name "color" :value "blue"}
{:name "shadow" :value "yes"}
{:name "border" :value "no"}
{:name "background" :value "none"}]))
(t/is (= (ctv/reorder-by-moving-to-position props 0 4) [{:name "color" :value "blue"}
{:name "shadow" :value "yes"}
{:name "background" :value "none"}
{:name "border" :value "no"}]))
(t/is (= (ctv/reorder-by-moving-to-position props 3 0) [{:name "background" :value "none"}
{:name "border" :value "no"}
{:name "color" :value "blue"}
{:name "shadow" :value "yes"}]))
(t/is (= (ctv/reorder-by-moving-to-position props 3 2) [{:name "border" :value "no"}
{:name "color" :value "blue"}
{:name "background" :value "none"}
{:name "shadow" :value "yes"}]))
(t/is (= (ctv/reorder-by-moving-to-position props 0 5) [{:name "color" :value "blue"}
{:name "shadow" :value "yes"}
{:name "background" :value "none"}
{:name "border" :value "no"}]))
(t/is (= (ctv/reorder-by-moving-to-position props 3 -1) [{:name "background" :value "none"}
{:name "border" :value "no"}
{:name "color" :value "blue"}
{:name "shadow" :value "yes"}]))
(t/is (= (ctv/reorder-by-moving-to-position props 5 -1) [{:name "background" :value "none"}
{:name "border" :value "no"}
{:name "color" :value "blue"}
{:name "shadow" :value "yes"}]))
(t/is (= (ctv/reorder-by-moving-to-position props -1 5) [{:name "color" :value "blue"}
{:name "shadow" :value "yes"}
{:name "background" :value "none"}
{:name "border" :value "no"}])))))

View File

@ -271,7 +271,7 @@ test("Bug 9066 - Problem with grid layout", async ({ page }) => {
await workspacePage.clickToggableLayer("Group");
await page.getByText("A", { exact: true }).click();
await workspacePage.rightSidebar.getByTestId("swap-component-btn").click();
await workspacePage.rightSidebar.getByTestId("component-pill-button").click();
await page.getByTitle("C", { exact: true }).click();

View File

@ -262,6 +262,28 @@
(dch/commit-changes changes)
(dwu/commit-undo-transaction undo-id))))))
(defn reorder-variant-poperties
"Reorder properties by moving a property from some position to some space between positions"
[variant-id from-pos to-space-between-pos]
(ptk/reify ::reorder-variant-properties
ptk/WatchEvent
(watch [it state _]
(let [page-id (:current-page-id state)
data (dsh/lookup-file-data state)
objects (-> (dsh/get-page data page-id)
(get :objects))
changes (-> (pcb/empty-changes it page-id)
(pcb/with-library-data data)
(pcb/with-objects objects)
(clvp/generate-reorder-variant-poperties variant-id from-pos to-space-between-pos))
undo-id (js/Symbol)]
(rx/of
(dwu/start-undo-transaction undo-id)
(dch/commit-changes changes)
(dwu/commit-undo-transaction undo-id))))))
(defn- set-variant-id
"Sets the variant-id on a component"
[component-id variant-id]

View File

@ -65,10 +65,10 @@
(def sortable-ctx (mf/create-context nil))
(mf/defc sortable-container
[{:keys [children] :as props}]
(mf/defc sortable-container*
[{:keys [children]}]
(let [global-drag-end (mf/use-memo #(rx/subject))]
[:& (mf/provider sortable-ctx) {:value global-drag-end}
[:> (mf/provider sortable-ctx) {:value global-drag-end}
children]))

View File

@ -356,7 +356,7 @@
:icon i/add}]]]
[:div {:class (stl/css :gradient-stops-list)}
[:& h/sortable-container {}
[:> h/sortable-container* {}
(for [[index stop] (d/enumerate stops)]
[:> stop-input-row*
{:key index

View File

@ -72,7 +72,7 @@
highlighted (hooks/use-equal-memo highlighted)
root (get objects uuid/zero)]
[:div {:class (stl/css :element-list) :data-testid "layer-item"}
[:& hooks/sortable-container {}
[:> hooks/sortable-container* {}
(for [[index id] (reverse (d/enumerate (:shapes root)))]
(when-let [obj (get objects id)]
(if (cfh/frame-shape? obj)

View File

@ -23,7 +23,7 @@
[app.main.ui.workspace.sidebar.options.drawing :as drawing]
[app.main.ui.workspace.sidebar.options.menus.align :refer [align-options*]]
[app.main.ui.workspace.sidebar.options.menus.bool :refer [bool-options*]]
[app.main.ui.workspace.sidebar.options.menus.component :refer [component-menu]]
[app.main.ui.workspace.sidebar.options.menus.component :refer [component-menu*]]
[app.main.ui.workspace.sidebar.options.menus.grid-cell :as grid-cell]
[app.main.ui.workspace.sidebar.options.menus.interactions :refer [interactions-menu]]
[app.main.ui.workspace.sidebar.options.menus.layout-container :as layout-container]
@ -89,7 +89,7 @@
{::mf/private true}
[{:keys [panel]}]
(when (= (:type panel) :component-swap)
[:& component-menu {:shapes (:shapes panel) :swap-opened? true}]))
[:> component-menu* {:shapes (:shapes panel) :is-swap-opened true}]))
(mf/defc design-menu*
{::mf/private true}

View File

@ -29,10 +29,12 @@
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]]
[app.main.ui.components.reorder-handler :refer [reorder-handler*]]
[app.main.ui.components.search-bar :refer [search-bar*]]
[app.main.ui.components.select :refer [select]]
[app.main.ui.components.title-bar :refer [title-bar*]]
[app.main.ui.context :as ctx]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.combobox :refer [combobox*]]
[app.main.ui.ds.controls.select :refer [select*]]
@ -149,12 +151,11 @@
(dw/set-annotations-id-for-create nil))
(dw/update-component-annotation component-id nil)
(rerender-fn)))]
(st/emit! (modal/show
{:type :confirm
:title (tr "modals.delete-component-annotation.title")
:message (tr "modals.delete-component-annotation.message")
:accept-label (tr "ds.confirm-ok")
:on-accept on-accept})))))]
(st/emit! (modal/show {:type :confirm
:title (tr "modals.delete-component-annotation.title")
:message (tr "modals.delete-component-annotation.message")
:accept-label (tr "ds.confirm-ok")
:on-accept on-accept})))))]
(mf/with-effect [shape-id state create-id creating?]
(when-let [textarea (mf/ref-val textarea-ref)]
@ -171,31 +172,28 @@
(st/emit! (dw/set-annotations-id-for-create nil)))))
(when (or creating? annotation)
[:div {:class (stl/css-case
:component-annotation true
:editing editing?
:creating creating?)}
[:div {:class (stl/css-case
:annotation-title true
:expandeable (not (or editing? creating?))
:expanded expanded?)
[:div {:class (stl/css-case :annotation true
:editing editing?
:creating creating?)}
[:div {:class (stl/css-case :annotation-title true
:expandeable (not (or editing? creating?))
:expanded expanded?)
:on-click on-toggle-expand}
(if (or editing? creating?)
[:span {:class (stl/css :annotation-text)}
[:span {:class (stl/css :annotation-title-name)}
(if editing?
(tr "workspace.options.component.edit-annotation")
(tr "workspace.options.component.create-annotation"))]
[:*
[:span {:class (stl/css-case
:icon-arrow true
:expanded expanded?)}
deprecated-icon/arrow]
[:span {:class (stl/css :annotation-text)}
[:> icon* {:icon-id (if expanded? i/arrow-down i/arrow-right)
:class (stl/css :annotation-title-icon-arrow)
:size "s"}]
[:span {:class (stl/css :annotation-title-name)}
(tr "workspace.options.component.annotation")]])
[:div {:class (stl/css :icons-wrapper)}
[:div {:class (stl/css :annotation-title-actions)}
(when (and ^boolean main-instance?
^boolean expanded?)
(if (or ^boolean editing?
@ -205,40 +203,41 @@
(tr "labels.create")
(tr "labels.save"))
:on-click on-save
:class (stl/css-case
:icon true
:icon-tick true
:invalid invalid-text?)}
deprecated-icon/tick]
[:div {:class (stl/css :icon :icon-cross)
:class (stl/css :annotation-title-icon-action)}
[:> icon* {:icon-id i/tick
:class (stl/css-case :annotation-title-icon-ok true
:disabled invalid-text?)}]]
[:div {:class (stl/css :annotation-title-icon-action)
:title (tr "labels.discard")
:on-click on-discard}
deprecated-icon/close]]
[:> icon* {:icon-id i/close
:class (stl/css :annotation-title-icon-nok)}]]]
[:*
[:div {:class (stl/css :icon :icon-edit)
[:div {:class (stl/css :annotation-title-icon-action)
:title (tr "labels.edit")
:on-click on-edit}
deprecated-icon/curve]
[:div {:class (stl/css :icon :icon-trash)
[:> icon* {:icon-id i/curve
:class (stl/css :annotation-title-icon-ok)}]]
[:div {:class (stl/css :annotation-title-icon-action)
:title (tr "labels.delete")
:on-click on-delete-annotation}
deprecated-icon/delete]]))]]
[:> icon* {:icon-id i/delete
:class (stl/css :annotation-title-icon-nok)}]]]))]]
[:div {:class (stl/css-case :hidden (not expanded?))}
[:div {:class (stl/css :grow-wrap)}
[:div {:class (stl/css :texarea-copy)}]
[:textarea
{:ref textarea-ref
:id "annotation-textarea"
:data-debug annotation
:auto-focus (or editing? creating?)
:maxLength 300
:on-input adjust-textarea-size
:default-value annotation
:read-only (not (or creating? editing?))}]]
[:div {:class (stl/css-case :annotation-body-hidden (not expanded?))}
[:div {:class (stl/css :annotation-body)}
[:textarea {:ref textarea-ref
:id "annotation-textarea"
:class (stl/css :annotation-textarea)
:data-debug annotation
:auto-focus (or editing? creating?)
:max-length 300
:on-input adjust-textarea-size
:default-value annotation
:read-only (not (or creating? editing?))}]]
(when (or editing? creating?)
[:div {:class (stl/css :counter)} (str size "/300")])]])))
[:div {:class (stl/css :annotation-counter)} (str size "/300")])]])))
(defn- get-variant-malformed-warning-message
"Receive a list of booleans, one for each selected variant, indicating if that variant
@ -300,7 +299,47 @@
(mapv (fn [val] {:id val
:label (if (str/blank? val) (str "(" (tr "labels.empty") ")") val)}))))
(mf/defc component-variant-main-instance*
(mf/defc component-variant-property*
[{:keys [pos prop options on-prop-name-blur on-prop-value-change on-reorder]}]
(let [on-drop
(mf/use-fn
(fn [relative-pos data]
(let [from-pos (:from-pos data)
to-space-between-pos (if (= relative-pos :bot) (inc pos) pos)]
(on-reorder from-pos to-space-between-pos))))
[dprops dref]
(h/use-sortable
:data-type "penpot/variant-property"
:on-drop on-drop
:draggable? true
:data {:from-pos pos})]
[:div {:class (stl/css-case :variant-property true
:dnd-over-top (= (:over dprops) :top)
:dnd-over-bot (= (:over dprops) :bot))}
(when (some? on-reorder)
[:> reorder-handler* {:ref dref}])
[:div {:class (stl/css :variant-property-container)}
[:div {:class (stl/css :variant-property-name-wrapper)}
[:> input-with-meta* {:value (:name prop)
:is-editing (:editing? (meta prop))
:max-length ctv/property-max-length
:data-position pos
:on-blur on-prop-name-blur}]]
[:div {:class (stl/css :variant-property-value-wrapper)}
(let [mixed-value? (= (:value prop) false)]
[:> combobox* {:id (str "variant-prop-" pos)
:placeholder (if mixed-value? (tr "settings.multiple") "--")
:default-selected (if mixed-value? "" (:value prop))
:options options
:empty-to-end true
:max-length ctv/property-max-length
:on-change on-prop-value-change}])]]]))
(mf/defc component-variant*
[{:keys [components shapes data]}]
(let [component (first components)
@ -356,33 +395,28 @@
int)]
(when (seq value)
(st/emit!
(dwv/update-property-name variant-id pos value {:trigger "workspace:design-tab-variant"}))))))]
(dwv/update-property-name variant-id pos value {:trigger "workspace:design-tab-variant"}))))))
reorder-properties
(mf/use-fn
(mf/deps variant-id)
(fn [from-pos to-space-between-pos]
(st/emit! (dwv/reorder-variant-poperties variant-id from-pos to-space-between-pos))))]
[:*
[:div {:class (stl/css :variant-property-list)}
(for [[pos prop] (map-indexed vector properties)]
[:div {:key (str variant-id "-" pos)
:class (stl/css :variant-property-container)}
[:div {:class (stl/css :variant-property-name-wrapper)}
[:> input-with-meta* {:value (:name prop)
:is-editing (:editing? (meta prop))
:max-length ctv/property-max-length
:data-position pos
:on-blur update-property-name}]]
[:div {:class (stl/css :variant-property-value-wrapper)}
(let [mixed-value? (= (:value prop) false)]
[:> combobox* {:id (str "variant-prop-" variant-id "-" pos)
:placeholder (if mixed-value? (tr "settings.multiple") "--")
:default-selected (if mixed-value? "" (:value prop))
:options (get-options (:name prop))
:empty-to-end true
:max-length ctv/property-max-length
:on-change (partial update-property-value pos)}])]])]
[:> h/sortable-container* {}
[:div {:class (stl/css :variant-property-list)}
(for [[pos prop] (map-indexed vector properties)]
[:> component-variant-property* {:key (str variant-id "-" pos)
:pos pos
:prop prop
:options (get-options (:name prop))
:on-prop-name-blur update-property-name
:on-prop-value-change (partial update-property-value pos)
:on-reorder reorder-properties}])]]
(if malformed-msg
[:div {:class (stl/css :variant-warning-wrapper)}
[:div {:class (stl/css :variant-warning)}
[:> icon* {:icon-id i/msg-neutral
:class (stl/css :variant-warning-darken)}]
[:div {:class (stl/css :variant-warning-highlight)}
@ -391,7 +425,7 @@
(tr "workspace.options.component.variant.malformed.structure.example")]]
(when duplicated-msg
[:div {:class (stl/css :variant-warning-wrapper)}
[:div {:class (stl/css :variant-warning)}
[:> icon* {:icon-id i/msg-neutral
:class (stl/css :variant-warning-darken)}]
[:div {:class (stl/css :variant-warning-highlight)}
@ -471,7 +505,7 @@
[:*
[:div {:class (stl/css :variant-property-list)}
(for [[pos prop] (map vector (range) props-first)]
(for [[pos prop] (map-indexed vector props-first)]
(let [mixed-value? (not-every? #(= (:value prop) (:value (nth % pos))) properties)
options (cond-> (get-options (:name prop))
mixed-value?
@ -492,7 +526,7 @@
:key (str (:value prop) "-" key)}]]]))]
(if (seq malformed-comps)
[:div {:class (stl/css :variant-warning-wrapper)}
[:div {:class (stl/css :variant-warning)}
[:> icon* {:icon-id i/msg-neutral
:class (stl/css :variant-warning-darken)}]
[:div {:class (stl/css :variant-warning-highlight)}
@ -502,7 +536,7 @@
(tr "workspace.options.component.variant.malformed.locate")]]
(when (seq duplicated-comps)
[:div {:class (stl/css :variant-warning-wrapper)}
[:div {:class (stl/css :variant-warning)}
[:> icon* {:icon-id i/msg-neutral
:class (stl/css :variant-warning-darken)}]
[:div {:class (stl/css :variant-warning-highlight)}
@ -523,43 +557,40 @@
item-ref (mf/use-ref)
visible? (h/use-visible item-ref :once? true)]
[:div {:ref item-ref
:class (stl/css-case :component-item (not listing-thumbs)
:grid-cell listing-thumbs
:selected (= (:id item) component-id)
:disabled loop)
:key (str "swap-item-" (:id item))
:on-click on-select}
[:button {:ref item-ref
:key (str "swap-item-" (:id item))
:class (stl/css-case :swap-item-list (not listing-thumbs)
:swap-item-grid listing-thumbs
:selected (= (:id item) component-id))
:on-click on-select
:disabled loop}
(when visible?
[:> cmm/component-item-thumbnail*
{:file-id (:file-id item)
:class (stl/css :component-img)
:root-shape root-shape
:component item
:container container}])
[:> cmm/component-item-thumbnail* {:file-id (:file-id item)
:class (stl/css :swap-item-thumbnail)
:root-shape root-shape
:component item
:container container}])
[:span {:title (if is-search (:full-name item) (:name item))
:class (stl/css-case :component-name true
:selected (= (:id item) component-id))}
:class (stl/css :swap-item-name)}
(if is-search (:full-name item) (:name item))]
(when (ctk/is-variant? item)
[:span {:class (stl/css-case :variant-mark-cell listing-thumbs
:variant-icon true)
[:span {:class (stl/css :swap-item-variant-icon)
:title (tr "workspace.assets.components.num-variants" num-variants)}
[:> icon* {:icon-id i/variant
:size "s"}]])]))
(mf/defc component-group-item*
(mf/defc component-swap-group-title*
[{:keys [item on-enter-group]}]
(let [group-name (:name item)
(let [group-name (:name item)
on-group-click #(on-enter-group group-name)]
[:div {:class (stl/css :component-group)
[:div {:class (stl/css :swap-group)
:on-click on-group-click
:title group-name}
[:span {:class (stl/css :component-group-name)}
[:span {:class (stl/css :swap-group-name)}
(cpn/last-path group-name)]
[:> icon* {:class (stl/css :component-group-icon)
[:> icon* {:class (stl/css :swap-group-icon)
:variant "ghost"
:icon-id i/arrow-right
:size "s"}]]))
@ -621,7 +652,7 @@
filters (deref filters*)
is-search? (not (str/blank? (:term filters)))
search? (not (str/blank? (:term filters)))
current-library-id (if (contains? libraries (:file-id filters))
(:file-id filters)
@ -662,15 +693,15 @@
(distinct)
(filter #(= (cpn/butlast-path %) (:path filters))))
groups (when-not is-search?
groups (when-not search?
(->> (sort (sequence xform components))
(map (fn [name] {:name name}))))
components (if is-search?
components (if search?
(filter #(str/includes? (str/lower (:full-name %)) (str/lower (:term filters))) components)
(filter #(= (:path %) (:path filters)) components))
items (if (or is-search? (:listing-thumbs? filters))
items (if (or search? (:listing-thumbs? filters))
(sort-by :full-name components)
(->> (concat groups components)
(sort-by :name)))
@ -686,7 +717,8 @@
;; Get the ids of the components that are parents of the shapes, to avoid loops
parent-components (mapcat find-parent-components shapes)
libraries-options (map (fn [library] {:value (:id library) :label (:name library)})
libraries-options (map (fn [library] {:value (:id library)
:label (:name library)})
(vals libraries))
on-library-change
@ -714,63 +746,60 @@
(mf/use-fn
(fn [style]
(swap! filters* assoc :listing-thumbs? (= style "grid"))))
filter-path-with-dots (->> (:path filters) (cpn/split-path) (cpn/join-path-with-dot))]
[:div {:class (stl/css :component-swap)}
[:div {:class (stl/css :element-set-title)}
filter-path-with-dots (->> (:path filters)
(cpn/split-path)
(cpn/join-path-with-dot))]
[:div {:class (stl/css :swap)}
[:div {:class (stl/css :swap-title)}
[:span (tr "workspace.options.component.swap")]]
[:div {:class (stl/css :component-swap-content)}
[:div {:class (stl/css :fields-wrapper)}
[:div {:class (stl/css :search-field)}
[:> search-bar* {:on-change on-search-term-change
:on-clear on-search-clear-click
:class (stl/css :search-wrapper)
:id "swap-component-search-filter"
:value (:term filters)
:placeholder (str (tr "labels.search") " " (get-in libraries [current-library-id :name]))
:icon-id i/search}]]
[:& select {:class (stl/css :select-library)
:default-value current-library-id
[:div {:class (stl/css :swap-content)}
[:div {:class (stl/css :swap-filters)}
[:> search-bar* {:id "swap-component-search-filter"
:icon-id i/search
:value (:term filters)
:placeholder (str (tr "labels.search") " " (get-in libraries [current-library-id :name]))
:on-change on-search-term-change
:on-clear on-search-clear-click}]
[:& select {:default-value current-library-id
:options libraries-options
:on-change on-library-change}]]
[:div {:class (stl/css :swap-wrapper)}
[:div {:class (stl/css :library-name-wrapper)}
[:div {:class (stl/css :library-name)} current-lib-name]
[:div {:class (stl/css :swap-library)}
[:div {:class (stl/css :swap-library-title)}
[:div {:class (stl/css :swap-library-name)} current-lib-name]
[:& radio-buttons {:selected (if (:listing-thumbs? filters) "grid" "list")
:on-change toggle-list-style
:name "swap-listing-style"}
[:& radio-button {:icon deprecated-icon/view-as-list
:value "list"
:id "swap-opt-list"}]
[:& radio-button {:icon deprecated-icon/flex-grid
:value "grid"
:id "swap-opt-grid"}]]]
[:div {:class (stl/css :listing-options-wrapper)}
[:& radio-buttons {:class (stl/css :listing-options)
:selected (if (:listing-thumbs? filters) "grid" "list")
:on-change toggle-list-style
:name "swap-listing-style"}
[:& radio-button {:icon deprecated-icon/view-as-list
:value "list"
:id "swap-opt-list"}]
[:& radio-button {:icon deprecated-icon/flex-grid
:value "grid"
:id "swap-opt-grid"}]]]]
(when-not (or is-search? (str/empty? (:path filters)))
[:button {:class (stl/css :component-path)
(when-not (or search? (str/empty? (:path filters)))
[:button {:class (stl/css :swap-library-back)
:on-click on-go-back
:title filter-path-with-dots}
[:> icon* {:icon-id i/arrow-left
:size "s"}]
[:span {:class (stl/css :path-name)}
[:span {:class (stl/css :swap-library-back-name)}
filter-path-with-dots]])
(when (empty? items)
[:div {:class (stl/css :component-list-empty)}
[:div {:class (stl/css :swap-library-empty)}
(tr "workspace.options.component.swap.empty")]) ;;TODO review this empty space
(when (:listing-thumbs? filters)
[:div {:class (stl/css :component-list)}
[:div
(for [item groups]
[:> component-group-item* {:item item :on-enter-group on-enter-group}])])
[:> component-swap-group-title* {:item item
:on-enter-group on-enter-group}])])
[:div {:class (stl/css-case :component-grid (:listing-thumbs? filters)
:component-list (not (:listing-thumbs? filters)))}
[:div {:class (stl/css-case :swap-library-grid (:listing-thumbs? filters)
:swap-library-list (not (:listing-thumbs? filters)))}
;; FIXME: This could be in the thousands. We need to think about paginate this
(for [item items]
(if (:id item)
@ -789,34 +818,78 @@
:root-shape root-shape
:container container
:component-id component-id
:is-search is-search?
:is-search search?
:listing-thumbs (:listing-thumbs? filters)
:num-variants (count-variants item)}])
[:> component-group-item* {:item item
:key (:name item)
:on-enter-group on-enter-group}]))]]]]))
[:> component-swap-group-title* {:item item
:key (:name item)
:on-enter-group on-enter-group}]))]]]]))
(mf/defc component-ctx-menu*
[{:keys [menu-entries on-close show main-instance]}]
(let [do-action
(mf/defc component-pill*
[{:keys [icon text subtext menu-entries disabled on-click]}]
(let [menu-open* (mf/use-state false)
menu-open? (deref menu-open*)
menu-entries? (seq menu-entries)
on-menu-click
(mf/use-fn
(mf/deps menu-open* menu-open?)
(fn [event]
(dom/prevent-default event)
(dom/stop-propagation event)
(reset! menu-open* (not menu-open?))))
on-menu-close
(mf/use-fn
(mf/deps menu-open*)
#(reset! menu-open* false))
do-action
(fn [action event]
(dom/stop-propagation event)
(action)
(on-close))]
[:& dropdown {:show show :on-close on-close}
[:ul {:class (stl/css-case :custom-select-dropdown true
:not-main (not main-instance))}
(for [{:keys [title action]} menu-entries]
(when (some? title)
[:li {:key title
:class (stl/css :dropdown-element)
:on-click (partial do-action action)}
[:span {:class (stl/css :dropdown-label)} title]]))]]))
(on-menu-close))]
(mf/defc component-menu
{::mf/props :obj}
[{:keys [shapes swap-opened?]}]
[:div {:class (stl/css :pill)}
[:button {:class (stl/css-case :pill-btn true
:with-menu menu-entries?)
:data-testid "component-pill-button"
:on-click on-click
:disabled disabled}
[:div {:class (stl/css :pill-btn-icon)}
[:> icon* {:size "s"
:icon-id icon}]]
[:div {:class (stl/css :pill-btn-name)}
[:div {:class (stl/css :pill-btn-text)}
text]
(when subtext
[:div {:class (stl/css :pill-btn-subtext)}
subtext])]]
(when menu-entries?
[:div {:class (stl/css :pill-actions)}
[:button {:class (stl/css-case :pill-actions-btn true
:selected menu-open?)
:on-click on-menu-click}
[:> icon* {:icon-id i/menu}]]
[:& dropdown {:show menu-open?
:on-close on-menu-close}
[:ul {:class (stl/css-case :pill-actions-dropdown true
:extended subtext)}
(for [{:keys [title action]} menu-entries]
(when (some? title)
[:li {:key title
:class (stl/css :pill-actions-dropdown-item)
:on-click (partial do-action action)}
[:span title]]))]]])]))
(mf/defc component-menu*
[{:keys [shapes is-swap-opened]}]
(let [current-file-id (mf/use-ctx ctx/current-file-id)
libraries (mf/deref refs/files)
@ -827,7 +900,6 @@
:menu-open false}))
state (deref state*)
open? (:show-content state)
menu-open? (:menu-open state)
shapes (filter ctk/instance-head? shapes)
multi (> (count shapes) 1)
@ -855,18 +927,8 @@
main-instance? (ctk/main-instance? shape)
toggle-content
(mf/use-fn #(swap! state* update :show-content not))
on-menu-click
(mf/use-fn
(fn [event]
(dom/prevent-default event)
(dom/stop-propagation event)
(swap! state* update :menu-open not)))
on-menu-close
(mf/use-fn
#(swap! state* assoc :menu-open false))
#(swap! state* update :show-content not))
on-click-variant-title-help
(mf/use-fn
@ -923,14 +985,13 @@
(swap! state* update :render inc)))
menu-entries (cmm/generate-components-menu-entries shapes {:for-design-tab? true})
show-menu? (seq menu-entries)
path (->> component (:path) (cpn/split-path) (cpn/join-path-with-dot))]
(when (seq shapes)
[:div {:class (stl/css :element-set)}
[:div {:class (stl/css :element-title)}
(if swap-opened?
[:button {:class (stl/css :title-back)
[:div {:class (stl/css :component-section)}
[:div {:class (stl/css :component-title)}
(if is-swap-opened
[:button {:class (stl/css :component-title-swap)
:on-click on-component-back}
[:> icon* {:icon-id i/arrow-left
:size "s"}]
@ -941,9 +1002,9 @@
:collapsed (not open?)
:on-collapsed toggle-content
:title (tr "workspace.options.component")
:class (stl/css :title-spacing-component)
:title-class (stl/css :title-bar-variant)}
[:span {:class (stl/css :copy-text)}
:class (stl/css :component-title-bar)
:title-class (stl/css :component-title-bar-title)}
[:span {:class (stl/css :component-title-bar-type)}
(if main-instance?
(if is-variant?
(tr "labels.variant")
@ -963,82 +1024,56 @@
:icon i/variant}])])]
(when open?
[:div {:class (stl/css :element-content)}
[:div {:class (stl/css :component-line)}
[:div {:class (stl/css :component-wrapper)}
[:button {:class (stl/css-case :component-name-wrapper true
:without-menu (not show-menu?))
:data-testid "swap-component-btn"
:on-click open-component-panel
:disabled (or swap-opened? (not can-swap?))}
[:div {:class (stl/css :component-icon)}
[:> icon* {:size "s"
:icon-id (if main-instance?
(if is-variant? i/variant i/component)
i/component-copy)}]]
[:div {:class (stl/css :component-name-outside)}
[:div {:class (stl/css :component-name)}
[:span {:class (stl/css :component-name-inside)}
(if (and multi (not same-variant?))
(tr "settings.multiple")
(cpn/last-path shape-name))]]
(when (and can-swap? (or (not multi) same-variant?))
[:div {:class (stl/css :component-parent-name)}
(if (:deleted component)
(tr "workspace.options.component.unlinked")
(cpn/merge-path-item-with-dot path (:name component)))])]]
(when show-menu?
[:div {:class (stl/css :component-actions)}
[:button {:class (stl/css-case :component-menu-btn true
:selected menu-open?)
:on-click on-menu-click}
[:> icon* {:icon-id i/menu}]]
[:> component-ctx-menu* {:show menu-open?
:on-close on-menu-close
:menu-entries menu-entries
:main-instance main-instance?}]])]
[:div {:class (stl/css :component-content)}
[:div {:class (stl/css :component-pill)}
[:> component-pill* {:icon (if main-instance?
(if is-variant? i/variant i/component)
i/component-copy)
:text (if (and multi (not same-variant?))
(tr "settings.multiple")
(cpn/last-path shape-name))
:subtext (when (and can-swap? (or (not multi) same-variant?))
(if (:deleted component)
(tr "workspace.options.component.unlinked")
(cpn/merge-path-item-with-dot path (:name component))))
:on-click open-component-panel
:disabled (or is-swap-opened (not can-swap?))
:menu-entries menu-entries}]
(when (and is-variant? main-instance?)
[:> icon-button* {:variant "ghost"
:aria-label (tr "workspace.shape.menu.add-variant-property")
:on-click add-new-property
:icon i/add}])]
(when swap-opened?
(when is-swap-opened
[:> component-swap* {:shapes copies}])
(when (and is-variant?
(not main-instance?)
(not (:deleted component))
(not swap-opened?)
(not is-swap-opened)
(or (not multi) same-variant?))
[:> component-variant-copy* {:current-file-id current-file-id
:components components
:shapes shapes
:component-file-data data}])
(when (and is-variant? main-instance? same-variant? (not swap-opened?))
[:> component-variant-main-instance* {:components components
:shapes shapes
:data data}])
(when (and is-variant? main-instance? same-variant? (not is-swap-opened))
[:> component-variant* {:components components
:shapes shapes
:data data}])
(when (and (not swap-opened?) (not multi))
(when (and (not is-swap-opened) (not multi))
[:> component-annotation* {:id id
:shape shape
:component component
:rerender-fn rerender-fn}])
(when (and multi all-main? (not any-variant?))
[:button {:class (stl/css :combine-variant-button)
:on-click on-combine-as-variants}
[:span (tr "workspace.shape.menu.combine-as-variants")]])
[:> button* {:variant "secondary"
:class (stl/css :component-combine)
:on-click on-combine-as-variants}
(tr "workspace.shape.menu.combine-as-variants")])
(when (dbg/enabled? :display-touched)
[:div ":touched " (str (:touched shape))])])])))
@ -1050,7 +1085,50 @@
(into (remove empty?) v)
(into (filter empty?) v)))
(mf/defc variant-menu*
(mf/defc component-variant-main-property*
[{:keys [pos property is-remove-disabled on-remove on-blur on-reorder]}]
(let [values (->> (:value property)
(move-empty-items-to-end)
(replace {"" "--"})
(str/join ", "))
on-drop
(mf/use-fn
(fn [relative-pos data]
(let [from-pos (:from-pos data)
to-space-between-pos (if (= relative-pos :bot) (inc pos) pos)]
(on-reorder from-pos to-space-between-pos))))
[dprops dref]
(h/use-sortable
:data-type "penpot/variant-main-property"
:on-drop on-drop
:draggable? true
:data {:from-pos pos})]
[:div {:class (stl/css-case :variant-property true
:dnd-over-top (= (:over dprops) :top)
:dnd-over-bot (= (:over dprops) :bot))}
(when (some? on-reorder)
[:> reorder-handler* {:ref dref}])
[:div {:class (stl/css :variant-property-row)}
[:> input-with-meta* {:value (:name property)
:data-position pos
:meta values
:is-editing (:editing? (meta property))
:max-length ctv/property-max-length
:on-blur on-blur}]
[:> icon-button* {:variant "ghost"
:icon i/remove
:data-position pos
:aria-label (if is-remove-disabled
(tr "workspace.shape.menu.remove-variant-property.last-property")
(tr "workspace.shape.menu.remove-variant-property"))
:on-click on-remove
:disabled is-remove-disabled}]]]))
(mf/defc component-variant-main*
[{:keys [shapes]}]
(let [multi? (> (count shapes) 1)
@ -1080,13 +1158,11 @@
properties (mf/with-memo [data objects variant-id]
(cfv/extract-properties-values data objects (:id shape)))
single-property? (= (count properties) 1)
open* (mf/use-state true)
open? (deref open*)
menu-open* (mf/use-state false)
menu-open? (deref menu-open*)
show-in-assets-panel
(mf/use-fn
(mf/deps variants)
@ -1119,19 +1195,6 @@
(mf/use-fn
#(swap! open* not))
on-menu-click
(mf/use-fn
(mf/deps menu-open* menu-open?)
(fn [event]
(dom/prevent-default event)
(dom/stop-propagation event)
(reset! menu-open* (not menu-open?))))
on-menu-close
(mf/use-fn
(mf/deps menu-open*)
#(reset! menu-open* false))
on-click-variant-title-help
(mf/use-fn
(fn []
@ -1162,6 +1225,12 @@
(ev/event {::ev/name "variant-remove-property" ::ev/origin "workspace:button-design-tab"})
(dwv/remove-property variant-id pos))))))
reorder-properties
(mf/use-fn
(mf/deps variant-id)
(fn [from-pos to-space-between-pos]
(st/emit! (dwv/reorder-variant-poperties variant-id from-pos to-space-between-pos))))
select-shapes-with-malformed
(mf/use-fn
(mf/deps malformed-ids)
@ -1173,95 +1242,56 @@
#(st/emit! (dw/select-shapes (into (d/ordered-set) duplicated-ids))))]
(when (seq shapes)
[:div {:class (stl/css :element-set)}
[:div {:class (stl/css :element-title)}
[:div {:class (stl/css :component-section)}
[:div {:class (stl/css :component-title)}
[:*
[:> title-bar* {:collapsable true
:collapsed (not open?)
:on-collapsed toggle-content
:title (tr "workspace.options.component")
:class (stl/css :title-spacing-component)
:title-class (stl/css :title-bar-variant)}
[:span {:class (stl/css :copy-text)}
:class (stl/css :component-title-bar)
:title-class (stl/css :component-title-bar-title)}
[:span {:class (stl/css :component-title-bar-type)}
(tr "workspace.options.component.main")]]
[:div {:class (stl/css :title-actions)}
[:> icon-button* {:variant "ghost"
:aria-label (tr "workspace.options.component.variants-help-modal.title")
:on-click on-click-variant-title-help
:icon i/help}]
[:> icon-button* {:variant "ghost"
:aria-label (tr "workspace.shape.menu.add-variant")
:on-click (partial create-variant "workspace:button-design-tab-component")
:icon i/variant}]]]]
[:> icon-button* {:variant "ghost"
:aria-label (tr "workspace.options.component.variants-help-modal.title")
:on-click on-click-variant-title-help
:icon i/help}]
[:> icon-button* {:variant "ghost"
:aria-label (tr "workspace.shape.menu.add-variant")
:on-click (partial create-variant "workspace:button-design-tab-component")
:icon i/variant}]]]
(when open?
[:div {:class (stl/css :element-content)}
[:div {:class (stl/css :component-line)}
[:div {:class (stl/css :component-wrapper)}
[:button {:class (stl/css :component-name-wrapper)
:disabled true}
[:div {:class (stl/css :component-icon)}
[:> icon* {:size "s"
:icon-id i/component}]]
[:div {:class (stl/css :component-name-outside)}
[:div {:class (stl/css :component-name)}
[:span {:class (stl/css :component-name-inside)}
(if multi?
(tr "settings.multiple")
(cpn/last-path shape-name))]]]]
(when-not multi?
[:div {:class (stl/css :component-actions)}
[:button {:class (stl/css-case :component-menu-btn true
:selected menu-open?)
:on-click on-menu-click}
[:> icon* {:icon-id i/menu}]]
[:> component-ctx-menu* {:show menu-open?
:on-close on-menu-close
:menu-entries menu-entries
:main-instance true}]])]
[:div {:class (stl/css :component-content)}
[:div {:class (stl/css :component-pill)}
[:> component-pill* {:icon i/component
:text (if multi?
(tr "settings.multiple")
(cpn/last-path shape-name))
:disabled true
:menu-entries menu-entries}]
[:> icon-button* {:variant "ghost"
:aria-label (tr "workspace.shape.menu.add-variant-property")
:on-click (partial add-new-property "workspace:button-design-tab-component")
:icon i/add}]]
(when-not multi?
[:div {:class (stl/css :variant-property-list)}
(for [[pos property] (map-indexed vector properties)]
(let [last-prop? (<= (count properties) 1)
values (->> (:value property)
(move-empty-items-to-end)
(replace {"" "--"})
(str/join ", "))
is-editing (:editing? (meta property))]
[:div {:key (str (:id shape) pos)
:class (stl/css :variant-property-row)}
[:> input-with-meta* {:value (:name property)
:meta values
:is-editing is-editing
:max-length ctv/property-max-length
:data-position pos
:on-blur update-property-name}]
[:> icon-button* {:variant "ghost"
:aria-label (if last-prop?
(tr "workspace.shape.menu.remove-variant-property.last-property")
(tr "workspace.shape.menu.remove-variant-property"))
:on-click remove-property
:data-position pos
:icon i/remove
:disabled last-prop?}]]))])
[:> h/sortable-container* {}
[:div {:class (stl/css :variant-property-list)}
(for [[pos property] (map-indexed vector properties)]
[:> component-variant-main-property* {:key (str (:id shape) pos)
:pos pos
:property property
:is-remove-disabled single-property?
:on-remove remove-property
:on-blur update-property-name
:on-reorder reorder-properties}])]])
(if malformed?
[:div {:class (stl/css :variant-warning-wrapper)}
[:div {:class (stl/css :variant-warning)}
[:> icon* {:icon-id i/msg-neutral
:class (stl/css :variant-warning-darken)}]
[:div {:class (stl/css :variant-warning-highlight)}
@ -1271,7 +1301,7 @@
(tr "workspace.options.component.variant.malformed.group.locate")]]
(when duplicated?
[:div {:class (stl/css :variant-warning-wrapper)}
[:div {:class (stl/css :variant-warning)}
[:> icon* {:icon-id i/msg-neutral
:class (stl/css :variant-warning-darken)}]
[:div {:class (stl/css :variant-warning-highlight)}

View File

@ -250,7 +250,7 @@
:icon i/remove}]]
(some? fills)
[:& h/sortable-container {}
[:> h/sortable-container* {}
(for [value fills]
(let [mdata (meta value)
index (get mdata :index)

View File

@ -812,7 +812,7 @@
[:button {:class (stl/css :add-column) :on-click add-track} deprecated-icon/add]]
(when expanded?
[:& h/sortable-container {}
[:> h/sortable-container* {}
[:div {:class (stl/css :grid-tracks-info-container)}
(for [[index column] (d/enumerate column-values)]
[:& grid-track-info {:key (dm/str index "-" (d/name type))

View File

@ -356,7 +356,7 @@
:icon i/remove}]]]]
(some? shadows)
[:& h/sortable-container {}
[:> h/sortable-container* {}
[:div {:class (stl/css :element-set-content)}
(for [{:keys [::index id] :as shadow} shadows]
[:> shadow-entry*

View File

@ -203,7 +203,7 @@
:on-click handle-remove-all
:icon i/remove}]]
(seq strokes)
[:& h/sortable-container {}
[:> h/sortable-container* {}
(for [[index value] (d/enumerate (:strokes values []))]
[:& stroke-row {:key (dm/str "stroke-" index)
:stroke value

View File

@ -12,7 +12,7 @@
[app.main.refs :as refs]
[app.main.ui.workspace.sidebar.options.menus.blur :refer [blur-menu]]
[app.main.ui.workspace.sidebar.options.menus.color-selection :refer [color-selection-menu*]]
[app.main.ui.workspace.sidebar.options.menus.component :refer [component-menu variant-menu*]]
[app.main.ui.workspace.sidebar.options.menus.component :refer [component-menu* component-variant-main*]]
[app.main.ui.workspace.sidebar.options.menus.constraints :refer [constraint-attrs constraints-menu]]
[app.main.ui.workspace.sidebar.options.menus.exports :refer [exports-menu* exports-attrs]]
[app.main.ui.workspace.sidebar.options.menus.fill :as fill]
@ -107,10 +107,10 @@
:type shape-type
:shapes shapes}]
[:& component-menu {:shapes shapes}]
[:> component-menu* {:shapes shapes}]
(when is-variant?
[:> variant-menu* {:shapes shapes}])
[:> component-variant-main* {:shapes shapes}])
[:& layout-container-menu
{:type shape-type

View File

@ -22,7 +22,7 @@
[app.main.refs :as refs]
[app.main.ui.workspace.sidebar.options.menus.blur :refer [blur-attrs blur-menu]]
[app.main.ui.workspace.sidebar.options.menus.color-selection :refer [color-selection-menu*]]
[app.main.ui.workspace.sidebar.options.menus.component :refer [component-menu]]
[app.main.ui.workspace.sidebar.options.menus.component :refer [component-menu*]]
[app.main.ui.workspace.sidebar.options.menus.constraints :refer [constraint-attrs constraints-menu]]
[app.main.ui.workspace.sidebar.options.menus.exports :refer [exports-attrs exports-menu*]]
[app.main.ui.workspace.sidebar.options.menus.fill :as fill]
@ -234,7 +234,7 @@
merge-token-values
(fn [acc shape-attrs applied-tokens]
"Merges token values across all shape attributes.
"Merges token values across all shape attributes.
For each shape attribute, its corresponding token attributes are merged
into the accumulator. If applied tokens are empty, the accumulator is returned unchanged."
(if (seq applied-tokens)
@ -455,7 +455,7 @@
:shapes shapes}])
(when (some? components)
[:& component-menu {:shapes components}])
[:> component-menu* {:shapes components}])
[:& layout-container-menu
{:type type

View File

@ -202,7 +202,7 @@
editing-page-id (mf/deref refs/editing-page-item)
current-page-id (mf/use-ctx ctx/current-page-id)]
[:ul {:class (stl/css :page-list)}
[:& hooks/sortable-container {}
[:> hooks/sortable-container* {}
(for [[index page-id] (d/enumerate pages)]
[:& page-item-wrapper
{:page-id page-id

View File

@ -51,7 +51,7 @@
(when-not token-set-new-path
[:> tsetslist/inline-add-button*])
[:> h/sortable-container {}
[:> h/sortable-container* {}
[:> tsets/sets-list*
{:tokens-lib tokens-lib
:new-path token-set-new-path