mirror of
https://github.com/penpot/penpot.git
synced 2025-12-11 22:14:05 +01:00
🎉 Use toggle for switching boolean variant property names (#7564)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -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*
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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};
|
||||
|
||||
78
frontend/src/app/main/ui/ds/controls/switch.cljs
Normal file
78
frontend/src/app/main/ui/ds/controls/switch.cljs
Normal 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)}]]]))
|
||||
52
frontend/src/app/main/ui/ds/controls/switch.mdx
Normal file
52
frontend/src/app/main/ui/ds/controls/switch.mdx
Normal 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
|
||||
118
frontend/src/app/main/ui/ds/controls/switch.scss
Normal file
118
frontend/src/app/main/ui/ds/controls/switch.scss
Normal 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);
|
||||
}
|
||||
65
frontend/src/app/main/ui/ds/controls/switch.stories.jsx
Normal file
65
frontend/src/app/main/ui/ds/controls/switch.stories.jsx
Normal 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>
|
||||
),
|
||||
};
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user