Skip to content

Commit

Permalink
feat: Mark empty style sources (#4685)
Browse files Browse the repository at this point in the history
[Discussion](https://discord.com/channels/955905230107738152/1323031888281075793)

## Description

As a designer, I want to know at a glance if the style source has no
defined values. Cases where this is useful:
1. Is anything defined on Local? E..g you sometimes forget to switch the
token and define things on local by accident
2. Is a token not having any value after it was created because value
went on some other token? again, possibly because token wasn't selected

Empty token should be a red flag, should basically never happen, but
empty local source would be most of the time

## Steps for reproduction

1. click button
2. expect xyz

## Code Review

- [ ] hi @kof, I need you to do
  - conceptual review (architecture, feature-correctness)
  - detailed review (read every line)
  - test it on preview

## Before requesting a review

- [ ] made a self-review
- [ ] added inline comments where things may be not obvious (the "why",
not "what")

## Before merging

- [ ] tested locally and on preview environment (preview dev login:
0000)
- [ ] updated [test
cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md)
document
- [ ] added tests
- [ ] if any new env variables are added, added them to `.env` file
  • Loading branch information
kof authored Jan 1, 2025
1 parent 53f03cf commit 874db0e
Show file tree
Hide file tree
Showing 7 changed files with 122 additions and 91 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ const $componentStates = computed(
);

type StyleSourceInputItem = {
id: string;
id: StyleSource["id"];
label: string;
disabled: boolean;
source: ItemSource;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,28 @@ const StyleSourceState = styled(Text, {
},
});

const LocalStyleIcon = ({ size = 16, showDot = true }) => {
return (
<svg viewBox="0 0 16 16" width={size} height={size} fill="none">
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
d="M8 14.5a6.5 6.5 0 1 0 0-13 6.5 6.5 0 0 0 0 13Z"
/>
{showDot && (
<path
fill="currentColor"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
d="M8 9.333a1.333 1.333 0 1 0 0-2.666 1.333 1.333 0 0 0 0 2.666Z"
/>
)}
</svg>
);
};

const errors = {
minlength: "Token must be at least 1 character long",
duplicate: "Token already exists",
Expand All @@ -254,14 +276,15 @@ export type StyleSourceError = {
type StyleSourceControlProps = {
id: StyleSource["id"];
error?: StyleSourceError;
children: ReactNode;
label: string;
menuItems: ReactNode;
selected: boolean;
state: undefined | string;
stateLabel: undefined | string;
disabled: boolean;
isEditing: boolean;
isDragging: boolean;
hasStyles: boolean;
source: ItemSource;
onSelect: () => void;
onChangeValue: (value: string) => void;
Expand All @@ -278,14 +301,14 @@ export const StyleSourceControl = ({
disabled,
isEditing,
isDragging,
hasStyles,
source,
children,
label,
onChangeValue,
onChangeEditing,
onSelect,
}: StyleSourceControlProps) => {
const showMenu = isEditing === false && isDragging === false;

return (
<Tooltip
content={error ? errors[error.type] : ""}
Expand All @@ -300,23 +323,37 @@ export const StyleSourceControl = ({
role="button"
hasError={error !== undefined}
>
<Flex grow css={{ py: theme.spacing[2], px: theme.spacing[3] }}>
<Flex
grow
css={{
position: "relative",
paddingBlock: theme.spacing[3],
paddingInline: theme.spacing[4],
}}
>
<StyleSourceButton
disabled={disabled || isEditing}
isEditing={isEditing}
onClick={onSelect}
tabIndex={-1}
>
{typeof children === "string" ? (
<EditableText
isEditing={isEditing}
onChangeEditing={onChangeEditing}
onChangeValue={onChangeValue}
value={children}
/>
) : (
children
)}
<Flex align="center" justify="center" gap="1">
{source === "local" ? (
<LocalStyleIcon showDot={hasStyles} />
) : (
<>
<EditableText
isEditing={isEditing}
onChangeEditing={onChangeEditing}
onChangeValue={onChangeValue}
value={label}
/>
{hasStyles === false && isEditing === false && (
<LocalStyleIcon showDot={hasStyles} />
)}
</>
)}
</Flex>
</StyleSourceButton>
</Flex>
{stateLabel !== undefined && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,12 @@ import {
rawTheme,
theme,
styled,
Flex,
ComboboxScrollArea,
InputField,
Flex,
Text,
} from "@webstudio-is/design-system";
import { CheckMarkIcon, DotIcon, LocalStyleIcon } from "@webstudio-is/icons";
import { CheckMarkIcon, DotIcon } from "@webstudio-is/icons";
import {
forwardRef,
useState,
Expand All @@ -58,9 +59,12 @@ import { useSortable } from "./use-sortable";
import { matchSorter } from "match-sorter";
import { StyleSourceBadge } from "./style-source-badge";
import { humanizeString } from "~/shared/string-utils";
import { $definedStyles } from "../shared/model";
import type { StyleDecl, StyleSource } from "@webstudio-is/sdk";
import { useStore } from "@nanostores/react";

type IntermediateItem = {
id: string;
id: StyleSource["id"];
label: string;
disabled: boolean;
source: ItemSource;
Expand All @@ -69,7 +73,7 @@ type IntermediateItem = {
};

export type ItemSelector = {
styleSourceId: IntermediateItem["id"];
styleSourceId: StyleSource["id"];
state?: string;
};

Expand Down Expand Up @@ -102,7 +106,7 @@ type TextFieldBaseWrapperProps<Item extends IntermediateItem> = Omit<
label: string;
containerRef?: RefObject<HTMLDivElement>;
inputRef?: RefObject<HTMLInputElement>;
renderStyleSourceMenuItems: (item: Item) => ReactNode;
renderStyleSourceMenuItems: (item: Item, hasStyles: boolean) => ReactNode;
onChangeItem?: (item: Item) => void;
onSort?: (items: Array<Item>) => void;
onSelectItem?: (itemSelector: ItemSelector) => void;
Expand All @@ -112,6 +116,27 @@ type TextFieldBaseWrapperProps<Item extends IntermediateItem> = Omit<
error?: StyleSourceError;
};

// Returns true if style source has defined styles including on the states.
const getHasStylesMap = <Item extends IntermediateItem>(
styleSourceItems: Array<Item>,
definedStyles: Set<Partial<StyleDecl>>
) => {
const map = new Map<Item["id"], boolean>();
for (const item of styleSourceItems) {
// Style source has styles on states.
if (item.states.length > 0) {
map.set(item.id, true);
}
for (const style of definedStyles) {
if (item.id === style.styleSourceId) {
map.set(item.id, true);
break;
}
}
}
return map;
};

const TextFieldBase: ForwardRefRenderFunction<
HTMLDivElement,
TextFieldBaseWrapperProps<IntermediateItem>
Expand Down Expand Up @@ -152,6 +177,16 @@ const TextFieldBase: ForwardRefRenderFunction<
internalInputRef.current?.focus();
}, [internalInputRef]);

const definedStyles = useStore($definedStyles);

const hasStyles = useCallback(
(styleSourceId: string) => {
const hasStylesMap = getHasStylesMap(value, definedStyles);
return hasStylesMap.get(styleSourceId) ?? false;
},
[value, definedStyles]
);

return (
<TextFieldContainer
{...focusWithinProps}
Expand Down Expand Up @@ -194,14 +229,15 @@ const TextFieldBase: ForwardRefRenderFunction<
{value.map((item) => (
<StyleSourceControl
key={item.id}
menuItems={renderStyleSourceMenuItems(item)}
menuItems={renderStyleSourceMenuItems(item, hasStyles(item.id))}
id={item.id}
selected={item.id === selectedItemSelector?.styleSourceId}
state={
item.id === selectedItemSelector?.styleSourceId
? selectedItemSelector.state
: undefined
}
label={item.label}
stateLabel={
item.id === selectedItemSelector?.styleSourceId
? states.find(
Expand All @@ -213,6 +249,7 @@ const TextFieldBase: ForwardRefRenderFunction<
disabled={item.disabled}
isDragging={item.id === dragItemId}
isEditing={item.id === editingItemId}
hasStyles={hasStyles(item.id)}
source={item.source}
onChangeEditing={(isEditing) => {
onEditItem?.(isEditing ? item.id : undefined);
Expand All @@ -222,15 +259,7 @@ const TextFieldBase: ForwardRefRenderFunction<
onEditItem?.();
onChangeItem?.({ ...item, label });
}}
>
{item.source === "local" ? (
<Flex align="center" justify="center">
<LocalStyleIcon />
</Flex>
) : (
item.label
)}
</StyleSourceControl>
/>
))}
{placementIndicator}
</TextFieldContainer>
Expand Down Expand Up @@ -314,6 +343,7 @@ const markAddedValues = <Item extends IntermediateItem>(
const renderMenuItems = (props: {
selectedItemSelector: undefined | ItemSelector;
item: IntermediateItem;
hasStyles: boolean;
states: ComponentState[];
onSelect?: (itemSelector: ItemSelector) => void;
onEdit?: (itemId: IntermediateItem["id"]) => void;
Expand All @@ -327,7 +357,16 @@ const renderMenuItems = (props: {
}) => {
return (
<>
<DropdownMenuLabel>{props.item.label}</DropdownMenuLabel>
<DropdownMenuLabel>
<Flex gap="1" justify="between" align="center">
<Text css={{ fontWeight: "bold" }} truncate>
{props.item.label}
</Text>
{props.hasStyles && (
<DotIcon size="12" color={rawTheme.colors.foregroundPrimary} />
)}
</Flex>
</DropdownMenuLabel>
{props.item.source !== "local" && (
<DropdownMenuItem onSelect={() => props.onEdit?.(props.item.id)}>
Rename
Expand Down Expand Up @@ -398,7 +437,7 @@ const renderMenuItems = (props: {
withIndicator={true}
icon={
props.item.id === props.selectedItemSelector?.styleSourceId &&
selector === props.selectedItemSelector.state ? (
selector === props.selectedItemSelector.state && (
<CheckMarkIcon
color={
props.item.states.includes(selector)
Expand All @@ -407,9 +446,7 @@ const renderMenuItems = (props: {
}
size={12}
/>
) : props.item.states.includes(selector) ? (
<DotIcon color={rawTheme.colors.foregroundPrimary} />
) : null
)
}
onSelect={() =>
props.onSelect?.({
Expand All @@ -422,7 +459,15 @@ const renderMenuItems = (props: {
})
}
>
{label}
<Flex justify="between" align="center" grow>
{label}
{props.item.states.includes(selector) && (
<DotIcon
size="12"
color={rawTheme.colors.foregroundPrimary}
/>
)}
</Flex>
</DropdownMenuItem>
))}
</Fragment>
Expand Down Expand Up @@ -511,10 +556,11 @@ export const StyleSourceInput = (
// @todo inputProps is any which breaks all types passed to TextField
{...inputProps}
error={props.error}
renderStyleSourceMenuItems={(item) =>
renderStyleSourceMenuItems={(item, hasStyles) =>
renderMenuItems({
selectedItemSelector: props.selectedItemSelector,
item,
hasStyles,
states,
onSelect: props.onSelectItem,
onDuplicate: props.onDuplicateItem,
Expand Down
2 changes: 2 additions & 0 deletions packages/design-system/src/components/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ import {
} from "@radix-ui/react-dropdown-menu";
import { CheckMarkIcon, DotIcon } from "@webstudio-is/icons";
import type { ComponentProps } from "react";
import { truncate } from "../utilities";

export const labelCss = css(textVariants.titles, {
color: theme.colors.foregroundMain,
mx: theme.spacing[3],
padding: theme.spacing[3],
order: 1,
...truncate(),
});

const indicatorSize = theme.spacing[9];
Expand Down
21 changes: 0 additions & 21 deletions packages/icons/icons/local-style.svg

This file was deleted.

Loading

0 comments on commit 874db0e

Please sign in to comment.