diff --git a/CHANGES.md b/CHANGES.md index 1e3caf8d7d..681e81f020 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -9,7 +9,9 @@ ### :heart: Community contributions (Thank you!) ### :sparkles: New features & Enhancements + - Select boards to export as PDF [Taiga #12320](https://tree.taiga.io/project/penpot/issue/12320) +- Toggle for switching boolean property values [Taiga #12341](https://tree.taiga.io/project/penpot/us/12341) ### :bug: Bugs fixed diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index ea81ca238d..015246c005 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -1048,6 +1048,12 @@ (into [elem]) (into (subvec without-elem insert-pos))))))) +(defn invert-map + "Returns a map with keys and values swapped. + If the input map has duplicate values, later entries overwrite earlier ones." + [m] + (into {} (map (fn [[k v]] [v k]) m))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; String Functions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/common/src/app/common/types/variant.cljc b/common/src/app/common/types/variant.cljc index bbac03d24a..63acbd706a 100644 --- a/common/src/app/common/types/variant.cljc +++ b/common/src/app/common/types/variant.cljc @@ -310,3 +310,17 @@ 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 find-boolean-pair + "Given a vector, return the map from 'bool-values' that contains both as keys. + Returns nil if none match." + [v] + (let [bool-values [{"on" true "off" false} + {"yes" true "no" false} + {"true" true "false" false}]] + (when (= (count v) 2) + (some (fn [b] + (when (and (contains? b (first v)) + (contains? b (last v))) + b)) + bool-values)))) diff --git a/common/test/common_tests/variant_test.cljc b/common/test/common_tests/variant_test.cljc index 05bc748703..85060cb241 100644 --- a/common/test/common_tests/variant_test.cljc +++ b/common/test/common_tests/variant_test.cljc @@ -159,3 +159,13 @@ (t/testing "update-number-in-repeated-prop-names" (t/is (= (ctv/update-number-in-repeated-prop-names props) numbered-props))))) + + +(t/deftest find-boolean-pair + (t/testing "find-boolean-pair" + (t/is (= (ctv/find-boolean-pair ["off" "on"]) {"on" true "off" false})) + (t/is (= (ctv/find-boolean-pair ["on" "off"]) {"on" true "off" false})) + (t/is (= (ctv/find-boolean-pair ["off" "on" "other"]) nil)) + (t/is (= (ctv/find-boolean-pair ["yes" "no"]) {"yes" true "no" false})) + (t/is (= (ctv/find-boolean-pair ["false" "true"]) {"true" true "false" false})) + (t/is (= (ctv/find-boolean-pair ["hello" "bye"]) nil)))) diff --git a/frontend/src/app/main/ui/ds.cljs b/frontend/src/app/main/ui/ds.cljs index 0c17ed51b2..4144bc9d6c 100644 --- a/frontend/src/app/main/ui/ds.cljs +++ b/frontend/src/app/main/ui/ds.cljs @@ -13,6 +13,7 @@ [app.main.ui.ds.controls.input :refer [input*]] [app.main.ui.ds.controls.numeric-input :refer [numeric-input*]] [app.main.ui.ds.controls.select :refer [select*]] + [app.main.ui.ds.controls.switch :refer [switch*]] [app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]] [app.main.ui.ds.controls.utilities.input-field :refer [input-field*]] [app.main.ui.ds.controls.utilities.label :refer [label*]] @@ -60,6 +61,7 @@ :Loader loader* :RawSvg raw-svg* :Select select* + :Switch switch* :Combobox combobox* :Text text* :TabSwitcher tab-switcher* diff --git a/frontend/src/app/main/ui/ds/_borders.scss b/frontend/src/app/main/ui/ds/_borders.scss index 2c3ee5cb84..9fff3615ac 100644 --- a/frontend/src/app/main/ui/ds/_borders.scss +++ b/frontend/src/app/main/ui/ds/_borders.scss @@ -7,9 +7,10 @@ @use "ds/_utils.scss" as *; // TODO: create actual tokens once we have them from design -$br-8: px2rem(8); $br-4: px2rem(4); $br-6: px2rem(6); +$br-8: px2rem(8); +$br-12: px2rem(12); $br-circle: 50%; $b-1: px2rem(1); diff --git a/frontend/src/app/main/ui/ds/colors.scss b/frontend/src/app/main/ui/ds/colors.scss index aa3d93bce6..d772dc41a9 100644 --- a/frontend/src/app/main/ui/ds/colors.scss +++ b/frontend/src/app/main/ui/ds/colors.scss @@ -79,6 +79,7 @@ $grayish-red: #bfbfbf; --color-accent-select: #{$purple-600-10}; --color-accent-action: #{$purple-400}; --color-accent-action-hover: #{$purple-500}; + --color-accent-off: #{$gray-50}; --color-accent-success: #{$green-500}; --color-background-success: #{$green-200}; @@ -92,6 +93,7 @@ $grayish-red: #bfbfbf; --color-background-default: #{$white}; --color-accent-default: #{$gray-100}; --color-icon-default: #{$blue-teal-700}; + --color-background-disabled: #{$gray-200}; --color-background-primary: #{$white}; --color-background-secondary: #{$gray-200}; @@ -105,6 +107,7 @@ $grayish-red: #bfbfbf; --color-static-black: #{$black}; --color-shadow-dark: #{color.change($gray-200, $alpha: 0.6)}; + --color-shadow-light: #{color.change($black, $alpha: 0.3)}; --color-overlay-default: #{$white-60}; --color-overlay-onboarding: #{$white-90}; --color-canvas: #{$grayish-red}; @@ -127,6 +130,7 @@ $grayish-red: #bfbfbf; --color-accent-select: #{$mint-250-10}; --color-accent-action: #{$purple-400}; --color-accent-action-hover: #{$purple-500}; + --color-accent-off: #{$gray-50}; --color-accent-success: #{$green-500}; --color-background-success: #{$green-950}; @@ -140,6 +144,7 @@ $grayish-red: #bfbfbf; --color-background-default: #{$gray-950}; --color-accent-default: #{$gray-800}; --color-icon-default: #{$grayish-blue-500}; + --color-background-disabled: #{$gray-800}; --color-background-primary: #{$gray-950}; --color-background-secondary: #{$black}; @@ -153,6 +158,7 @@ $grayish-red: #bfbfbf; --color-static-black: #{$black}; --color-shadow-dark: #{color.change($black, $alpha: 0.6)}; + --color-shadow-light: #{color.change($black, $alpha: 0.3)}; --color-overlay-default: #{$gray-950-60}; --color-overlay-onboarding: #{$gray-950-90}; --color-canvas: #{$grayish-red}; diff --git a/frontend/src/app/main/ui/ds/controls/switch.cljs b/frontend/src/app/main/ui/ds/controls/switch.cljs new file mode 100644 index 0000000000..a9b7f96c85 --- /dev/null +++ b/frontend/src/app/main/ui/ds/controls/switch.cljs @@ -0,0 +1,78 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.ds.controls.switch + (:require-macros + [app.main.style :as stl]) + (:require + [app.common.data :as d] + [app.util.dom :as dom] + [app.util.keyboard :as kbd] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(def ^:private schema:switch + [:map + [:id {:optional true} :string] + [:class {:optional true} :string] + [:label {:optional true} [:maybe :string]] + [:aria-label {:optional true} [:maybe :string]] + [:default-checked {:optional true} [:maybe :boolean]] + [:on-change {:optional true} [:maybe fn?]] + [:disabled {:optional true} :boolean]]) + +(mf/defc switch* + {::mf/schema schema:switch} + [{:keys [id class label aria-label default-checked on-change disabled] :rest props} ref] + (let [checked* (mf/use-state default-checked) + checked? (deref checked*) + + disabled? (d/nilv disabled false) + + has-label? (not (str/blank? label)) + + handle-toggle + (mf/use-fn + (mf/deps on-change checked? disabled?) + #(when-not disabled? + (let [updated-checked? (not checked?)] + (reset! checked* updated-checked?) + (when on-change + (on-change updated-checked?))))) + + handle-keydown + (mf/use-fn + (mf/deps handle-toggle) + (fn [event] + (dom/prevent-default event) + (when-not disabled? + (when (or (kbd/space? event) (kbd/enter? event)) + (handle-toggle event))))) + + props + (mf/spread-props props {:ref ref + :role "switch" + :aria-label (when-not has-label? + aria-label) + :class [class (stl/css-case :switch true + :off (false? checked?) + :neutral (nil? checked?) + :on (true? checked?))] + :aria-checked checked? + :tab-index (if disabled? -1 0) + :on-click handle-toggle + :on-key-down handle-keydown + :disabled disabled?})] + + [:> :div props + (when has-label? + [:label {:for id + :class (stl/css :switch-label)} + label]) + + [:div {:id id + :class (stl/css :switch-track)} + [:div {:class (stl/css :switch-thumb)}]]])) diff --git a/frontend/src/app/main/ui/ds/controls/switch.mdx b/frontend/src/app/main/ui/ds/controls/switch.mdx new file mode 100644 index 0000000000..e5720c9f20 --- /dev/null +++ b/frontend/src/app/main/ui/ds/controls/switch.mdx @@ -0,0 +1,52 @@ +{ /* This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + + Copyright (c) KALEIDOS INC */ } + +import { Canvas, Meta } from '@storybook/addon-docs/blocks'; +import * as Switch from "./switch.stories"; + + + +# Switch + +The `switch*` component is a toggle control that allows users to switch between two mutually exclusive options. + + + +## Anatomy + +The switch component consists of three main parts: + +- **Label** (optional): text that describes what the switch controls +- **Track**: the pill-shaped background that indicates the current state +- **Thumb**: the circular knob that moves between positions + +## Accesibility + +When no visible label is provided, use `aria-label` for accessibility. + +## Variants + +### With Label + +```clj +[:> switch* {:label "Toggle dark mode" + :default-checked false}] +``` + + + +## Usage Guidelines + +### When to Use + +- For binary settings that take effect immediately +- In preference panels and configuration screens + +### When Not to Use + +- For actions that require confirmation (use buttons instead) +- For multiple choice selections (use radio buttons or select) +- For temporary states that need explicit "Apply" action diff --git a/frontend/src/app/main/ui/ds/controls/switch.scss b/frontend/src/app/main/ui/ds/controls/switch.scss new file mode 100644 index 0000000000..c5abbf3a00 --- /dev/null +++ b/frontend/src/app/main/ui/ds/controls/switch.scss @@ -0,0 +1,118 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/colors.scss" as *; +@use "ds/spacing.scss" as *; +@use "ds/typography.scss" as t; + +.switch { + --switch-label-foreground-color: var(--color-foreground-primary); + + --switch-track-outline-color: none; + --switch-track-shadow: inset 0 1px 2px var(--color-shadow-light); + + --switch-thumb-shadow: 0 1px 2px var(--color-shadow-light); + + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: var(--sp-s); + inline-size: fit-content; + outline: none; + + &.off { + --switch-track-justify-content: start; + --switch-track-background-color: var(--color-foreground-secondary); + + --switch-thumb-width: #{px2rem(14)}; + --switch-thumb-height: #{px2rem(14)}; + --switch-thumb-background-color: var(--color-accent-off); + --switch-thumb-border-radius: #{$br-circle}; + } + + &.neutral { + --switch-track-justify-content: center; + --switch-track-background-color: var(--color-accent-tertiary); + + --switch-thumb-width: #{px2rem(14)}; + --switch-thumb-height: #{px2rem(4)}; + --switch-thumb-background-color: var(--color-accent-off); + --switch-thumb-border-radius: #{$br-8}; + } + + &.on { + --switch-track-justify-content: end; + --switch-track-background-color: var(--color-accent-tertiary); + + --switch-thumb-width: #{px2rem(14)}; + --switch-thumb-height: #{px2rem(14)}; + --switch-thumb-background-color: var(--color-accent-off); + --switch-thumb-border-radius: #{$br-circle}; + } + + &[disabled] { + pointer-events: none; + --switch-label-foreground-color: var(--color-foreground-secondary); + + --switch-track-shadow: none; + + --switch-thumb-shadow: none; + } + + &.off[disabled] { + --switch-track-background-color: var(--color-background-primary); + --switch-track-border-color: var(--color-background-disabled); + + --switch-thumb-background-color: var(--color-background-disabled); + } + + &.on[disabled], + &.neutral[disabled] { + --switch-track-background-color: var(--color-background-disabled); + + --switch-thumb-background-color: var(--color-background-primary); + } + + &:focus-visible { + --switch-track-outline-color: var(--color-accent-primary); + } + + &:hover { + --switch-thumb-background-color: var(--color-static-white); + } +} + +.switch-label { + @include t.use-typography("body-small"); + color: var(--switch-label-foreground-color); + user-select: none; +} + +.switch-track { + display: flex; + align-items: center; + justify-content: var(--switch-track-justify-content); + inline-size: px2rem(30); + block-size: px2rem(18); + padding: var(--sp-xxs); + border-radius: $br-12; + border: $b-1 solid var(--switch-track-border-color); + outline: $b-1 solid var(--switch-track-outline-color); + outline-offset: $b-1; + box-shadow: var(--switch-track-shadow); + background-color: var(--switch-track-background-color); +} + +.switch-thumb { + inline-size: var(--switch-thumb-width); + block-size: var(--switch-thumb-height); + border-radius: var(--switch-thumb-border-radius); + box-shadow: var(--switch-thumb-shadow); + background-color: var(--switch-thumb-background-color); +} diff --git a/frontend/src/app/main/ui/ds/controls/switch.stories.jsx b/frontend/src/app/main/ui/ds/controls/switch.stories.jsx new file mode 100644 index 0000000000..53593ca3d2 --- /dev/null +++ b/frontend/src/app/main/ui/ds/controls/switch.stories.jsx @@ -0,0 +1,65 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +import * as React from "react"; +import Components from "@target/components"; + +const { Switch } = Components; + +export default { + title: "Controls/Switch", + component: Switch, + argTypes: { + defaultChecked: { + control: { type: "boolean" }, + description: "Default checked state for uncontrolled mode", + }, + label: { + control: { type: "text" }, + description: "Label text displayed next to the switch", + }, + disabled: { + control: { type: "boolean" }, + description: "Whether the switch is disabled", + } + }, + args: { + defaultChecked: false, + disabled: false + }, + parameters: { + controls: { exclude: ["id", "class", "aria-label", "on-change"] }, + }, + render: ({ ...args }) => ( + + ), +}; + +export const Default = {}; + +export const WithLabel = { + args: { + defaultChecked: false, + label: "Enable something", + disabled: false, + }, + render: ({ ...args }) => ( + + ), +}; + +export const WithLongLabel = { + args: { + defaultChecked: false, + label: "This is a very long label that demonstrates how the switch component handles text wrapping and layout when the label content is extensive", + disabled: false, + }, + render: ({ ...args }) => ( +
+ +
+ ), +}; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs index 9ec3d75dc6..50b46b1627 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs @@ -38,6 +38,7 @@ [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*]] + [app.main.ui.ds.controls.switch :refer [switch*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.product.input-with-meta :refer [input-with-meta*]] [app.main.ui.hooks :as h] @@ -501,15 +502,26 @@ (reset! key* (uuid/next)) (st/emit! (ntf/error error-msg)))} params {:shapes shapes :pos pos :val val}] - (st/emit! (dwv/variants-switch (with-meta params mdata)))))))] + (st/emit! (dwv/variants-switch (with-meta params mdata))))))) + + switch-component-toggle + (mf/use-fn + (mf/deps shapes) + (fn [pos boolean-pair val] + (let [inverted-boolean-pair (d/invert-map boolean-pair) + val (get inverted-boolean-pair val)] + (switch-component pos val))))] [:* [:div {:class (stl/css :variant-property-list)} (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? - (conj {:id mixed-label, :label mixed-label :dimmed true}))] + options (get-options (:name prop)) + boolean-pair (ctv/find-boolean-pair (mapv :id options)) + options (cond-> options + mixed-value? + (conj {:id mixed-label :label mixed-label :dimmed true}))] + [:div {:key (str pos mixed-value?) :class (stl/css :variant-property-container)} @@ -518,12 +530,17 @@ [:div {:class (stl/css :variant-property-name)} (:name prop)]] - [:div {:class (stl/css :variant-property-value-wrapper)} - [:> select* {:default-selected (if mixed-value? mixed-label (:value prop)) - :options options - :empty-to-end true - :on-change (partial switch-component pos) - :key (str (:value prop) "-" key)}]]]))] + (if boolean-pair + [:div {:class (stl/css :variant-property-value-switch-wrapper)} + [:> switch* {:default-checked (if mixed-value? nil (get boolean-pair (:value prop))) + :on-change (partial switch-component-toggle pos boolean-pair) + :key (str (:value prop) "-" key)}]] + [:div {:class (stl/css :variant-property-value-wrapper)} + [:> select* {:default-selected (if mixed-value? mixed-label (:value prop)) + :options options + :empty-to-end true + :on-change (partial switch-component pos) + :key (str (:value prop) "-" key)}]])]))] (if (seq malformed-comps) [:div {:class (stl/css :variant-warning)} diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss index 553a786e57..46b457f281 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss @@ -662,6 +662,14 @@ align-self: center; } +.variant-property-value-switch-wrapper { + grid-column: span 5; + display: flex; + align-items: center; + block-size: $sz-32; + padding-inline-start: var(--sp-s); +} + .variant-property-name { @include t.use-typography("body-small"); color: var(--color-foreground-secondary);