🎉 Use toggle for switching boolean variant property names (#7564)

This commit is contained in:
Luis de Dios
2025-11-07 09:47:57 +01:00
committed by GitHub
parent ba092f03e1
commit f00fd1d5a8
13 changed files with 390 additions and 11 deletions

View File

@@ -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

View File

@@ -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
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@@ -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))))

View File

@@ -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))))

View File

@@ -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*

View File

@@ -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);

View File

@@ -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};

View File

@@ -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)}]]]))

View File

@@ -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";
<Meta title="Controls/Switch" />
# Switch
The `switch*` component is a toggle control that allows users to switch between two mutually exclusive options.
<Canvas of={Switch.Default} />
## 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}]
```
<Canvas of={Switch.WithLabel} />
## 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

View File

@@ -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);
}

View File

@@ -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 }) => (
<Switch {...args} />
),
};
export const Default = {};
export const WithLabel = {
args: {
defaultChecked: false,
label: "Enable something",
disabled: false,
},
render: ({ ...args }) => (
<Switch {...args} aria-label="Enable notification"/>
),
};
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 }) => (
<div style={{ maxWidth: "300px" }}>
<Switch {...args} />
</div>
),
};

View File

@@ -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)}

View File

@@ -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);