From 782bbe30d810056c25e4d24fadfa06444d87d11c Mon Sep 17 00:00:00 2001 From: Akshay Gupta Date: Wed, 1 Jul 2026 18:15:36 +0530 Subject: [PATCH 01/21] initial commit --- .../AppContainerIntegration.test.tsx | 2 + .../savePluginDataFactory.ts | 3 +- .../ExportProvider/MultiFilesExport.tsx | 13 +- .../ExportProvider/SingleFileExport.tsx | 13 +- .../app/components/ImportVariablesDialog.tsx | 49 +- .../ExportThemesTab.tsx | 86 ++- .../ManageStylesAndVariables/OptionsModal.tsx | 22 +- .../CreateOrEditThemeForm.tsx | 360 ++++++++--- .../ManageThemesModal/ManageThemesModal.tsx | 327 ++++++++-- .../ManageThemesModal/SingleThemeEntry.tsx | 20 +- .../StyledExtendedThemeGroup.tsx | 22 + .../ThemeListGroupHeader.tsx | 71 ++- .../ThemeListItemContent.tsx | 43 +- .../ManageThemesModal/TokenSetThemeItem.tsx | 62 +- .../ThemeSelector/ThemeSelector.tsx | 59 +- .../src/app/preview/preview.tsx | 1 + .../models/effects/settingsState/index.ts | 1 + .../setExportExtendedCollections.ts | 11 + .../models/reducers/settingsState/index.ts | 1 + .../setExportExtendedCollections.ts | 8 + .../models/reducers/tokenState/deleteTheme.ts | 42 +- .../reducers/tokenState/deleteThemeGroup.ts | 31 + .../store/models/reducers/tokenState/index.ts | 1 + .../models/reducers/tokenState/saveTheme.ts | 72 ++- .../src/app/store/models/settings.tsx | 2 + .../src/app/store/models/userState.ts | 8 + .../__tests__/extendedCollections.test.ts | 583 ++++++++++++++++++ .../extendedCollectionsOverrides.test.ts | 224 +++++++ .../getAvailableVariableCollections.test.ts | 21 +- .../getAvailableVariableCollections.ts | 52 +- .../asyncMessageHandlers/swapFigmaModes.ts | 46 +- .../plugin/createLocalVariablesInPlugin.ts | 14 +- ...reateLocalVariablesWithoutModesInPlugin.ts | 2 +- .../createNecessaryVariableCollections.ts | 95 ++- .../createExtendedCollections.ts | 112 ++++ .../importExtendedCollections.ts | 60 ++ .../src/plugin/extendedCollections/index.ts | 16 + .../src/plugin/pullVariables.test.ts | 13 + .../src/plugin/pullVariables.ts | 462 +++++++++----- .../src/plugin/setBooleanValuesOnVariable.ts | 17 +- .../src/plugin/setColorValuesOnVariable.ts | 24 +- .../src/plugin/setNumberValuesOnVariable.ts | 17 +- .../src/plugin/setStringValuesOnVariable.ts | 17 +- .../src/plugin/setValuesOnVariable.ts | 72 ++- .../src/plugin/updateVariables.ts | 37 +- .../src/plugin/updateVariablesFromPlugin.ts | 113 ++-- .../src/plugin/updateVariablesToReference.ts | 150 ++++- .../src/profiling/transaction.ts | 3 + .../exportExtendedCollectionsSelector.ts | 7 + .../src/selectors/index.ts | 2 + .../selectors/isFigmaEnterpriseSelector.ts | 4 + .../src/storage/schemas/themeObjectSchema.ts | 5 + .../src/types/ThemeObject.ts | 7 + .../src/types/VariableCollectionSelection.ts | 8 +- .../findOrderableTargetIndexesInThemeList.tsx | 18 +- .../src/utils/plugin/startup.ts | 20 + .../src/utils/themeListToTree.ts | 45 +- 57 files changed, 3101 insertions(+), 495 deletions(-) create mode 100644 packages/tokens-studio-for-figma/src/app/components/ManageThemesModal/StyledExtendedThemeGroup.tsx create mode 100644 packages/tokens-studio-for-figma/src/app/store/models/effects/settingsState/setExportExtendedCollections.ts create mode 100644 packages/tokens-studio-for-figma/src/app/store/models/reducers/settingsState/setExportExtendedCollections.ts create mode 100644 packages/tokens-studio-for-figma/src/app/store/models/reducers/tokenState/deleteThemeGroup.ts create mode 100644 packages/tokens-studio-for-figma/src/plugin/__tests__/extendedCollections.test.ts create mode 100644 packages/tokens-studio-for-figma/src/plugin/__tests__/extendedCollectionsOverrides.test.ts create mode 100644 packages/tokens-studio-for-figma/src/plugin/extendedCollections/createExtendedCollections.ts create mode 100644 packages/tokens-studio-for-figma/src/plugin/extendedCollections/importExtendedCollections.ts create mode 100644 packages/tokens-studio-for-figma/src/plugin/extendedCollections/index.ts create mode 100644 packages/tokens-studio-for-figma/src/selectors/exportExtendedCollectionsSelector.ts create mode 100644 packages/tokens-studio-for-figma/src/selectors/isFigmaEnterpriseSelector.ts diff --git a/packages/tokens-studio-for-figma/src/app/components/AppContainer/__tests__/AppContainerIntegration.test.tsx b/packages/tokens-studio-for-figma/src/app/components/AppContainer/__tests__/AppContainerIntegration.test.tsx index 7b75626675..ffafb31d73 100644 --- a/packages/tokens-studio-for-figma/src/app/components/AppContainer/__tests__/AppContainerIntegration.test.tsx +++ b/packages/tokens-studio-for-figma/src/app/components/AppContainer/__tests__/AppContainerIntegration.test.tsx @@ -146,6 +146,8 @@ const mockStartupParams: Omit = { usedEmail: null, authData: null, selectedExportThemes: [], + activeOrganizationId: null, + isFigmaEnterprise: false, }; // #region helpers diff --git a/packages/tokens-studio-for-figma/src/app/components/AppContainer/startupProcessSteps/savePluginDataFactory.ts b/packages/tokens-studio-for-figma/src/app/components/AppContainer/startupProcessSteps/savePluginDataFactory.ts index 1ee44128a5..c6b02ab854 100644 --- a/packages/tokens-studio-for-figma/src/app/components/AppContainer/startupProcessSteps/savePluginDataFactory.ts +++ b/packages/tokens-studio-for-figma/src/app/components/AppContainer/startupProcessSteps/savePluginDataFactory.ts @@ -7,7 +7,7 @@ import pjs from '../../../../../package.json'; export function savePluginDataFactory(dispatch: Dispatch, params: StartupMessage) { return async () => { - const { user, usedEmail } = params; + const { user, usedEmail, isFigmaEnterprise } = params; if (user) { // initiate analytics if (user.userId) { @@ -30,6 +30,7 @@ export function savePluginDataFactory(dispatch: Dispatch, params: StartupMessage }; dispatch.userState.setUserId(user.figmaId); dispatch.userState.setUsedEmail(usedEmail ?? undefined); + dispatch.userState.setIsFigmaEnterprise(isFigmaEnterprise ?? false); dispatch.uiState.setLastOpened(params.lastOpened); dispatch.uiState.setOnboardingExplainerSets(params.onboardingExplainer.sets); dispatch.uiState.setOnboardingExplainerExportSets(params.onboardingExplainer.exportSets); diff --git a/packages/tokens-studio-for-figma/src/app/components/ExportProvider/MultiFilesExport.tsx b/packages/tokens-studio-for-figma/src/app/components/ExportProvider/MultiFilesExport.tsx index 169bf04e69..fa7f29d7f6 100644 --- a/packages/tokens-studio-for-figma/src/app/components/ExportProvider/MultiFilesExport.tsx +++ b/packages/tokens-studio-for-figma/src/app/components/ExportProvider/MultiFilesExport.tsx @@ -28,7 +28,18 @@ export default function MultiFilesExport({ onClose }: Props) { Object.entries(convertTokensToObject(tokens, storeTokenIdInJsonEditor)).forEach(([key, value]) => { changeObj[`${key}.json`] = JSON.stringify(value, null, 2); }); - changeObj[`${SystemFilenames.THEMES}.json`] = JSON.stringify(themes, null, 2); + // Filter out internal metadata from themes before export + const cleanedThemes = themes.map((theme) => { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + $figmaParentCollectionId, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + $figmaIsExtension, + ...rest + } = theme; + return rest; + }); + changeObj[`${SystemFilenames.THEMES}.json`] = JSON.stringify(cleanedThemes, null, 2); const metadata = { tokenSetOrder: Object.keys(tokens), }; diff --git a/packages/tokens-studio-for-figma/src/app/components/ExportProvider/SingleFileExport.tsx b/packages/tokens-studio-for-figma/src/app/components/ExportProvider/SingleFileExport.tsx index cd6e63ff13..035c875490 100644 --- a/packages/tokens-studio-for-figma/src/app/components/ExportProvider/SingleFileExport.tsx +++ b/packages/tokens-studio-for-figma/src/app/components/ExportProvider/SingleFileExport.tsx @@ -67,7 +67,18 @@ export default function SingleFileExport({ onClose }: Props) { const exportData = React.useMemo(() => { const returnValue = JSON.parse(formattedTokens); if (includeAllTokens) { - set(returnValue, SystemFilenames.THEMES, themes); + // Filter out internal metadata from themes before export + const cleanedThemes = themes.map((theme) => { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + $figmaParentCollectionId, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + $figmaIsExtension, + ...rest + } = theme; + return rest; + }); + set(returnValue, SystemFilenames.THEMES, cleanedThemes); set(returnValue, SystemFilenames.METADATA, { tokenSetOrder: Object.keys(tokens), }); diff --git a/packages/tokens-studio-for-figma/src/app/components/ImportVariablesDialog.tsx b/packages/tokens-studio-for-figma/src/app/components/ImportVariablesDialog.tsx index 83b61b5848..17bea62a83 100644 --- a/packages/tokens-studio-for-figma/src/app/components/ImportVariablesDialog.tsx +++ b/packages/tokens-studio-for-figma/src/app/components/ImportVariablesDialog.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useEffect } from 'react'; +import React, { useState, useCallback, useEffect, useMemo } from 'react'; import { Button, Checkbox, Label, Stack, Heading, } from '@tokens-studio/ui'; @@ -20,11 +20,27 @@ export default function ImportVariablesDialog({ const [useDimensions, setUseDimensions] = useState(false); const [useRem, setUseRem] = useState(false); - // Initialize all collections as selected with all modes selected by default + // Filter out multi-level extensions (depth > 1) + const { allowedCollections, filteredCollections } = useMemo(() => { + const allowed: VariableCollectionInfo[] = []; + const filtered: VariableCollectionInfo[] = []; + + collections.forEach((collection) => { + if (!collection.extensionDepth || collection.extensionDepth <= 1) { + allowed.push(collection); + } else { + filtered.push(collection); + } + }); + + return { allowedCollections: allowed, filteredCollections: filtered }; + }, [collections]); + + // Initialize all allowed collections as selected with all modes selected by default useEffect(() => { - if (collections.length > 0) { + if (allowedCollections.length > 0) { const initialSelection: SelectedCollections = {}; - collections.forEach((collection) => { + allowedCollections.forEach((collection) => { initialSelection[collection.id] = { name: collection.name, selectedModes: collection.modes.map((mode) => mode.modeId), @@ -32,7 +48,7 @@ export default function ImportVariablesDialog({ }); setSelectedCollections(initialSelection); } - }, [collections]); + }, [allowedCollections]); const handleCollectionToggle = useCallback((collectionId: string, collectionName: string, modes: { modeId: string; name: string }[]) => { setSelectedCollections((prev) => { @@ -104,19 +120,19 @@ export default function ImportVariablesDialog({ ); const hasSelections = Object.keys(selectedCollections).length > 0; - const allCollectionsSelected = collections.every((collection) => selectedCollections[collection.id]); + const allCollectionsSelected = allowedCollections.every((collection) => selectedCollections[collection.id]); const handleToggleAllCollections = useCallback(() => { // If all collections are selected, deselect all; otherwise, select all if (allCollectionsSelected) { setSelectedCollections({}); } else { - setSelectedCollections(collections.reduce((acc, collection) => ({ + setSelectedCollections(allowedCollections.reduce((acc, collection) => ({ ...acc, [collection.id]: { name: collection.name, selectedModes: collection.modes.map((mode) => mode.modeId) }, }), {})); } - }, [collections, allCollectionsSelected]); + }, [allowedCollections, allCollectionsSelected]); return ( - {collections.length === 0 ? ( + {allowedCollections.length === 0 ? ( - There are no collections present in this file + {filteredCollections.length > 0 + ? 'All collections in this file have more than one level of extension' + : 'There are no collections present in this file'} ) : ( - {collections.map((collection) => { + {allowedCollections.map((collection) => { const isCollectionSelected = !!selectedCollections[collection.id]; const selectedModes = selectedCollections[collection.id]?.selectedModes || []; @@ -234,6 +252,15 @@ export default function ImportVariablesDialog({ )} + {filteredCollections.length > 0 && ( + + ⚠️ {filteredCollections.length} collection(s) skipped: We only support one level of extension at the moment. + + )} + {!hasSelections && ( [t.id, t])); + const childrenByParent = new Map(); + const roots: string[] = []; + + themes.forEach((t) => { + if (t.$figmaParentThemeId && themesById.has(t.$figmaParentThemeId)) { + const children = childrenByParent.get(t.$figmaParentThemeId) || []; + children.push(t.id); + childrenByParent.set(t.$figmaParentThemeId, children); + } else { + roots.push(t.id); + } + }); + + const result: ThemeObject[] = []; + function pushWithChildren(id: string) { + const theme = themesById.get(id); + if (theme) { + result.push(theme); + const children = childrenByParent.get(id) || []; + children.forEach(pushWithChildren); + } + } + + roots.forEach(pushWithChildren); + return result; +} export default function ExportThemesTab({ selectedThemes, setSelectedThemes }: { selectedThemes: string[], setSelectedThemes: (themes: string[]) => void }) { const { t } = useTranslation(['manageStylesAndVariables']); @@ -40,17 +70,35 @@ export default function ExportThemesTab({ selectedThemes, setSelectedThemes }: { || (theme.group && theme.group.toLowerCase().includes(lowerSearchTerm))); }, [themes, searchTerm, isSearchActive]); + const sortedFilteredThemes = React.useMemo(() => sortThemesForDisplay(filteredThemes), [filteredThemes]); + const ThemeGroups = React.useMemo(() => { - const uniqueGroups: string[] = filteredThemes.reduce((unique: string[], theme) => { + const uniqueGroups: string[] = sortedFilteredThemes.reduce((unique: string[], theme) => { if (theme.group && !unique.includes(theme.group)) { unique.push(theme.group); } return unique; }, []); return uniqueGroups; - }, [filteredThemes]); + }, [sortedFilteredThemes]); - const ungroupedThemes = React.useMemo(() => filteredThemes.filter((theme) => !theme.group), [filteredThemes]); + // Map each group to its parent group (for extended collections) + const themeGroupParentMap = React.useMemo(() => { + const themeById = new Map(sortedFilteredThemes.map((t) => [t.id, t])); + const map = new Map(); + ThemeGroups.forEach((group) => { + const firstInGroup = sortedFilteredThemes.find((t) => (t.group ?? '') === group); + if (firstInGroup?.$figmaParentThemeId) { + const parentGroup = themeById.get(firstInGroup.$figmaParentThemeId)?.group ?? null; + map.set(group, parentGroup); + } else { + map.set(group, null); + } + }); + return map; + }, [ThemeGroups, sortedFilteredThemes]); + + const ungroupedThemes = React.useMemo(() => sortedFilteredThemes.filter((theme) => !theme.group), [sortedFilteredThemes]); const excludedSelectedThemesCount = useMemo(() => { if (!isSearchActive || !searchTerm) { @@ -70,7 +118,7 @@ export default function ExportThemesTab({ selectedThemes, setSelectedThemes }: { const handleSelectAllThemes = React.useCallback(() => { // When filtering, select/deselect all visible (filtered) themes - const themesToToggle = filteredThemes; + const themesToToggle = sortedFilteredThemes; const allFilteredSelected = themesToToggle.every((theme) => selectedThemes.includes(theme.id)); if (allFilteredSelected) { @@ -86,13 +134,15 @@ export default function ExportThemesTab({ selectedThemes, setSelectedThemes }: { }); setSelectedThemes(newSelection); } - }, [filteredThemes, selectedThemes, setSelectedThemes]); + }, [sortedFilteredThemes, selectedThemes, setSelectedThemes]); function createThemeRow(theme: ThemeObject) { return ( {/* eslint-disable-next-line react/jsx-no-bind */} handleSelectTheme(theme.id)} label={theme.name} /> @@ -159,25 +209,33 @@ export default function ExportThemesTab({ selectedThemes, setSelectedThemes }: {

{t('exportThemesTab.combinationsOfSetsMakeThemes')}

- 0 && filteredThemes.every((theme) => selectedThemes.includes(theme.id))} onCheckedChange={handleSelectAllThemes} /> + 0 && sortedFilteredThemes.every((theme) => selectedThemes.includes(theme.id))} onCheckedChange={handleSelectAllThemes} /> - {filteredThemes.length === 0 && isSearchActive && searchTerm ? ( + {sortedFilteredThemes.length === 0 && isSearchActive && searchTerm ? ( {t('noThemesFound')} ) : ( <> - {ThemeGroups.map((group) => ( - - {group} - {filteredThemes.filter((theme) => theme.group === group).map((theme) => createThemeRow(theme))} - - ))} + {(() => { + const renderGroup = (group: string, depth: number): React.ReactNode => { + const childGroups = ThemeGroups.filter((g) => themeGroupParentMap.get(g) === group); + return ( + 0 ? `${depth * 16}px` : undefined }}> + {group} + {getHierarchicalThemes(sortedFilteredThemes.filter((theme) => theme.group === group)).map((theme) => createThemeRow(theme))} + {childGroups.map((child) => renderGroup(child, depth + 1))} + + ); + }; + const rootGroups = ThemeGroups.filter((g) => !themeGroupParentMap.get(g)); + return rootGroups.map((group) => renderGroup(group, 0)); + })()} {ungroupedThemes.length ? ( {t('generic.noGroup')} - {ungroupedThemes.map((theme) => createThemeRow(theme))} + {getHierarchicalThemes(ungroupedThemes).map((theme) => createThemeRow(theme))} ) : null} diff --git a/packages/tokens-studio-for-figma/src/app/components/ManageStylesAndVariables/OptionsModal.tsx b/packages/tokens-studio-for-figma/src/app/components/ManageStylesAndVariables/OptionsModal.tsx index eba823aeae..f387fa61a9 100644 --- a/packages/tokens-studio-for-figma/src/app/components/ManageStylesAndVariables/OptionsModal.tsx +++ b/packages/tokens-studio-for-figma/src/app/components/ManageStylesAndVariables/OptionsModal.tsx @@ -24,6 +24,7 @@ import { stylesColorSelector, stylesEffectSelector, stylesTypographySelector, + exportExtendedCollectionsSelector, stylesGradientSelector, } from '@/selectors'; import ignoreFirstPartImage from '@/app/assets/hints/ignoreFirstPartForStyles.png'; @@ -52,6 +53,7 @@ export default function OptionsModal({ isOpen, title, closeAction }: { isOpen: b const stylesColor = useSelector(stylesColorSelector); const stylesTypography = useSelector(stylesTypographySelector); const stylesEffect = useSelector(stylesEffectSelector); + const exportExtendedCollections = useSelector(exportExtendedCollectionsSelector); const stylesGradient = useSelector(stylesGradientSelector); const dispatch = useDispatch(); @@ -91,6 +93,13 @@ export default function OptionsModal({ isOpen, title, closeAction }: { isOpen: b [dispatch.settings], ); + const handleExportExtendedCollectionsChange = React.useCallback( + (state: CheckedState) => { + dispatch.settings.setExportExtendedCollections(!!state); + }, + [dispatch.settings], + ); + const handleExportVariablesColor = React.useCallback( (state: CheckedState) => { dispatch.settings.setVariablesColor(!!state); @@ -175,7 +184,7 @@ export default function OptionsModal({ isOpen, title, closeAction }: { isOpen: b -)} + )} stickyFooter > @@ -269,6 +278,17 @@ export default function OptionsModal({ isOpen, title, closeAction }: { isOpen: b {t('options.removeWithoutConnectionExplanation')} + + + + When enabled, themes marked as "extended" will be exported as extended variable collections in Figma, inheriting from their parent collections. This is useful for creating brand variants or theme extensions. Requires Figma Enterprise plan. + diff --git a/packages/tokens-studio-for-figma/src/app/components/ManageThemesModal/CreateOrEditThemeForm.tsx b/packages/tokens-studio-for-figma/src/app/components/ManageThemesModal/CreateOrEditThemeForm.tsx index 23852a856a..7666a2cb6d 100644 --- a/packages/tokens-studio-for-figma/src/app/components/ManageThemesModal/CreateOrEditThemeForm.tsx +++ b/packages/tokens-studio-for-figma/src/app/components/ManageThemesModal/CreateOrEditThemeForm.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import { useForm, Controller, useWatch } from 'react-hook-form'; import { useSelector, useStore } from 'react-redux'; import { - Box, Button, IconButton, Stack, DropdownMenu, + Box, Button, IconButton, Stack, Select, Switch, DropdownMenu, } from '@tokens-studio/ui'; import { NavArrowLeft, FilterList, Check } from 'iconoir-react'; import { useTranslation } from 'react-i18next'; @@ -29,7 +29,9 @@ import { track } from '@/utils/analytics'; export type FormValues = { name: string group?: string + parentThemeId?: string tokenSets: Record + $figmaMirrorParentSets?: boolean }; export enum ThemeFormTabs { @@ -42,10 +44,12 @@ type Props = { defaultValues?: Partial onSubmit: (values: FormValues) => void onCancel: () => void + isExtendMode?: boolean + setIsExtendMode?: (value: boolean) => void }; export const CreateOrEditThemeForm: React.FC>> = ({ - id, defaultValues, onSubmit, onCancel, + id, defaultValues, onSubmit, onCancel, isExtendMode = false, setIsExtendMode, }) => { const store = useStore(); const [activeTab, setActiveTab] = useState(ThemeFormTabs.SETS); @@ -59,9 +63,59 @@ export const CreateOrEditThemeForm: React.FC ([...new Set(themes.filter((t) => t?.group).map((t) => t.group as string))]), [themes]); + // Filter out extended theme groups from the group selector + const groupNames = useMemo(() => { + const allGroups = [...new Set(themes.filter((t) => t?.group).map((t) => t.group as string))]; + return allGroups.filter((group) => { + // Exclude groups with "/" (hierarchical extended format) + if (group.includes('/')) { + return false; + } + // Exclude groups where any theme has $figmaParentThemeId + const themesInGroup = themes.filter(t => t.group === group); + return !themesInGroup.some(t => t.$figmaParentThemeId); + }); + }, [themes]); + + // Available parent theme GROUPS (not individual themes) + const availableParentGroups = useMemo(() => { + // Get all unique theme groups, excluding extended ones + const allGroups = new Set(); + + themes.forEach((theme) => { + if (theme.group) { + allGroups.add(theme.group); + } + }); + + // Filter out extended theme groups + return Array.from(allGroups) + .filter((group) => { + // Exclude groups with "/" (hierarchical extended format like "Parent/Extended") + if (group.includes('/')) { + return false; + } + // Exclude groups where any theme has $figmaParentThemeId (extended theme) + const themesInGroup = themes.filter(t => t.group === group); + return !themesInGroup.some(t => t.$figmaParentThemeId); + }) + .sort(); + }, [themes]); const { t } = useTranslation(['tokens', 'errors']); + // Get current theme if editing + const currentTheme = useMemo(() => { + if (!id) return null; + return themes.find(t => t.id === id); + }, [id, themes]); + + // Check if current theme is extended and find parent + const isExtendedTheme = useMemo(() => !!currentTheme?.$figmaParentThemeId, [currentTheme]); + const parentTheme = useMemo(() => { + if (!isExtendedTheme || !currentTheme?.$figmaParentThemeId) return null; + return themes.find(t => t.id === currentTheme.$figmaParentThemeId); + }, [isExtendedTheme, currentTheme, themes]); + const treeOrListItems = useMemo(() => ( githubMfsEnabled ? tokenSetListToTree(availableTokenSets) @@ -69,7 +123,7 @@ export const CreateOrEditThemeForm: React.FC({ defaultValues: { tokenSets: { ...selectedTokenSets }, @@ -79,6 +133,15 @@ export const CreateOrEditThemeForm: React.FC { + // If mirror is enabled and we have a parent theme, sync token sets + if (mirrorEnabled && parentTheme) { + setValue('tokenSets', parentTheme.selectedTokenSets); + } + }, [mirrorEnabled, parentTheme, setValue]); + const filteredTreeOrListItems = useMemo(() => { let baseItems = treeOrListItems; if (statusFilter !== 'all') { @@ -160,10 +223,11 @@ export const CreateOrEditThemeForm: React.FC )} /> - ), [control]); + ), [control, mirrorEnabled]); const handleAddGroup = React.useCallback(() => [ setShowGroupInput(true), @@ -261,62 +325,151 @@ export const CreateOrEditThemeForm: React.FC - - { - showGroupInput ? ( - - ) : ( - - { - groupNames.length > 0 ? ( + {/* EXTEND MODE: Show parent selector + name only */} + {!id && isExtendMode && ( + + {/* Only show parent selector if not already provided in defaultValues */} + {!defaultValues?.parentThemeId && ( + + + Extend from + ( - + )} /> - ) : ( - - ) - } - - ) - } - / - - - - +
+ )} + + + {t('themeName')} + + + + + )} + + {/* DEFAULT MODE: Show original group + name inputs */} + {!id && !isExtendMode && ( + <> + + { + showGroupInput ? ( + + ) : ( + + { + groupNames.length > 0 ? ( + ( + + )} + /> + ) : ( + + ) + } + + ) + } + / + + + + + + )} + + {/* EDITING MODE: Show group dropdown + name input */} + {id && ( + <> + + ( + + )} + /> + / + + + + + + )} + @@ -354,7 +507,7 @@ export const CreateOrEditThemeForm: React.FC )} - {!id && ( + {!id && !isExtendMode && ( @@ -376,10 +529,58 @@ export const CreateOrEditThemeForm: React.FC )} - {(id ? activeTab === ThemeFormTabs.SETS : true) && ( - - {(() => { - if (filteredTreeOrListItems.length > 0) { + {/* Mirror Parent Theme Toggle - only for extended themes */} + {id && isExtendedTheme && parentTheme && ( + + ( + + + + Mirror Parent Theme + + + Automatically sync token sets from {parentTheme.group ? `${parentTheme.group}/` : ''}{parentTheme.name} + + + + + )} + /> + + )} + {(id ? activeTab === ThemeFormTabs.SETS : !isExtendMode) && ( + + {(() => { + if (filteredTreeOrListItems.length > 0) { + return ( + + ); + } + if ((isSearchActive && searchTerm) || statusFilter !== 'all') { + return ( + + + {t('noItemsMatch')} + + + + ); + } return ( ); - } - if ((isSearchActive && searchTerm) || statusFilter !== 'all') { - return ( - - - {t('noItemsMatch')} - - - - ); - } - return ( - - ); - })()} - + })()} + )} {(activeTab === ThemeFormTabs.STYLES_VARIABLES && id) && ( - - - {t('stylesVarMultiDimensionalThemesWarning')} + + + {t('stylesVarMultiDimensionalThemesWarning')} + + - - )} diff --git a/packages/tokens-studio-for-figma/src/app/components/ManageThemesModal/ManageThemesModal.tsx b/packages/tokens-studio-for-figma/src/app/components/ManageThemesModal/ManageThemesModal.tsx index c031c0f627..76acc80fe2 100644 --- a/packages/tokens-studio-for-figma/src/app/components/ManageThemesModal/ManageThemesModal.tsx +++ b/packages/tokens-studio-for-figma/src/app/components/ManageThemesModal/ManageThemesModal.tsx @@ -46,22 +46,81 @@ export const ManageThemesModal: React.FC(false); + const [isExtendMode, setIsExtendMode] = useState(false); + const [selectedParentGroup, setSelectedParentGroup] = useState(undefined); const [themeListScrollPosition, setThemeListScrollPosition] = useState(0); const themeListRef = useRef(null); const treeItems = themeListToTree(themes); const { t } = useTranslation(['tokens']); - const themeEditorDefaultValues: Partial = useMemo(() => { + const themesById = useMemo(() => new Map(themes.map((t) => [t.id, t])), [themes]); + + const getThemeDepth = useCallback((themeId: string) => { + let depth = 0; + let currentId = themeId; + while (true) { + const theme = themesById.get(currentId); + if (theme?.$figmaParentThemeId && themesById.has(theme.$figmaParentThemeId)) { + depth += 1; + currentId = theme.$figmaParentThemeId; + } else { + break; + } + } + return depth; + }, [themesById]); + + const getGroupDepth = useCallback((groupName: string) => { + const firstThemeInGroup = themes.find((t) => t.group === groupName); + if (firstThemeInGroup) { + return getThemeDepth(firstThemeInGroup.id); + } + return 0; + }, [themes, getThemeDepth]); + + // Available parent theme GROUPS (not individual themes) + const availableParentGroups = useMemo(() => { + // Get all unique theme groups, excluding extended ones + const allGroups = new Set(); + + themes.forEach((theme) => { + if (theme.group) { + allGroups.add(theme.group); + } + }); + + // Filter out extended theme groups + return Array.from(allGroups) + .filter((group) => { + // Exclude groups with "/" (hierarchical extended format like "Parent/Extended") + if (group.includes('/')) { + return false; + } + // Exclude groups where any theme has $figmaParentThemeId (extended theme) + const themesInGroup = themes.filter(t => t.group === group); + return !themesInGroup.some(t => t.$figmaParentThemeId); + }) + .sort(); + }, [themes]); + + const themeEditorDefaultValues: Partial = useMemo(() => { const themeObject = themes.find(({ id }) => id === themeEditorOpen); if (themeObject) { return { name: themeObject.name, tokenSets: themeObject.selectedTokenSets, ...(themeObject?.group ? { group: themeObject.group } : {}), + $figmaMirrorParentSets: themeObject.$figmaMirrorParentSets, + }; + } + // If in extend mode, pre-populate parentThemeId with selected group + if (isExtendMode && selectedParentGroup) { + return { + parentThemeId: selectedParentGroup, }; } return {}; - }, [themes, themeEditorOpen]); + }, [themes, themeEditorOpen, isExtendMode, selectedParentGroup]); const handleClose = useCallback(() => { dispatch.uiState.setManageThemesModalOpen(false); @@ -96,8 +155,26 @@ export const ManageThemesModal: React.FC { + const themesInGroup = themes.filter((t) => t.group === groupName); + const confirmText = `Delete theme group "${groupName}" with ${themesInGroup.length} theme(s)?`; + + const confirmDelete = await confirm({ + text: confirmText, + confirmAction: t('delete'), + variant: 'danger', + }); + + if (confirmDelete) { + track('Delete theme group', { groupName, themeCount: themesInGroup.length }); + dispatch.tokenState.deleteThemeGroup(groupName); + } + }, [themes, confirm, dispatch, t]); + const handleCancelEdit = useCallback(() => { setThemeEditorOpen(false); + setIsExtendMode(false); + setSelectedParentGroup(undefined); }, []); const handleEscapeKeyDown = useCallback((event: KeyboardEvent) => { @@ -111,23 +188,82 @@ export const ManageThemesModal: React.FC { const id = typeof themeEditorOpen === 'string' ? themeEditorOpen : undefined; + if (id) { + // EDITING EXISTING THEME track('Edit theme', { id, values }); + + // If this is an extended theme with mirror enabled, use parent's token sets + const currentTheme = themes.find(t => t.id === id); + const shouldMirror = values.$figmaMirrorParentSets ?? currentTheme?.$figmaMirrorParentSets ?? false; + let tokenSetsToUse = values.tokenSets; + + if (shouldMirror && currentTheme?.$figmaParentThemeId) { + const parentTheme = themes.find(t => t.id === currentTheme.$figmaParentThemeId); + if (parentTheme) { + tokenSetsToUse = parentTheme.selectedTokenSets; + } + } + + dispatch.tokenState.saveTheme({ + id, + name: values.name, + selectedTokenSets: tokenSetsToUse, + ...(values?.group ? { group: values.group } : {}), + $figmaMirrorParentSets: values.$figmaMirrorParentSets, + meta: { + oldName: themeEditorDefaultValues?.name, + oldGroup: themeEditorDefaultValues?.group, + }, + }); + setThemeEditorOpen(false); + return; + } + + // CREATING NEW THEME(S) + track('Create theme', { values }); + + const parentGroupName = values.parentThemeId; // Group name to extend from + const parentThemes = parentGroupName + ? themes.filter(t => t.group === parentGroupName) // Include all themes in the parent group, even if already extended + : []; + + if (parentThemes.length > 0) { + // EXTENDING A GROUP: Create multiple themes (one for each mode in parent) + const newGroupName = values.name; + + parentThemes.forEach((parentTheme) => { + const themeData: any = { + name: parentTheme.name, // Keep same mode name (Light, Dark, etc.) + group: newGroupName, // Use user-provided name directly without auto-prefixing + selectedTokenSets: parentTheme.selectedTokenSets, // Copy parent's token sets + $figmaIsExtension: true, + $figmaParentThemeId: parentTheme.id, + $figmaParentCollectionId: parentTheme.$figmaCollectionId, + $figmaMirrorParentSets: true, // Enable mirroring by default + }; + + dispatch.tokenState.saveTheme(themeData); + }); + + track('Extended theme group created', { + parentGroup: parentGroupName, + newGroup: newGroupName, + themesCreated: parentThemes.length, + }); } else { - track('Create theme', { values }); + // REGULAR THEME: Create single theme + const themeData: any = { + name: values.name, + selectedTokenSets: values.tokenSets, + ...(values?.group ? { group: values.group } : {}), + }; + + dispatch.tokenState.saveTheme(themeData); } - dispatch.tokenState.saveTheme({ - id, - name: values.name, - selectedTokenSets: values.tokenSets, - ...(values?.group ? { group: values.group } : {}), - meta: { - oldName: themeEditorDefaultValues?.name, - oldGroup: themeEditorDefaultValues?.group, - }, - }); + setThemeEditorOpen(false); - }, [themeEditorOpen, dispatch.tokenState, themeEditorDefaultValues]); + }, [themeEditorOpen, dispatch.tokenState, themeEditorDefaultValues, themes]); const handleReorder = React.useCallback((reorderedItems: TreeItem[]) => { let currentGroup = ''; @@ -143,16 +279,68 @@ export const ManageThemesModal: React.FC new index position + const parentThemeOrderMap = new Map(); + updatedThemes.forEach((theme, index) => { + // Only track non-extended themes (parent themes) + if (!theme.$figmaParentThemeId) { + parentThemeOrderMap.set(theme.id, index); + } + }); + + // Sort extended themes based on their parent's new order + const finalThemes = updatedThemes.sort((a, b) => { + const aIsExtended = !!a.$figmaParentThemeId; + const bIsExtended = !!b.$figmaParentThemeId; + + // If neither is extended, keep original order + if (!aIsExtended && !bIsExtended) { + return updatedThemes.indexOf(a) - updatedThemes.indexOf(b); + } + + // If only one is extended, prioritize parent themes first + if (aIsExtended && !bIsExtended) { + return 1; + } + if (!aIsExtended && bIsExtended) { + return -1; + } + + // Both are extended - sort by parent theme order + const aParentOrder = parentThemeOrderMap.get(a.$figmaParentThemeId!) ?? Infinity; + const bParentOrder = parentThemeOrderMap.get(b.$figmaParentThemeId!) ?? Infinity; + + if (aParentOrder !== bParentOrder) { + return aParentOrder - bParentOrder; + } + + // Same parent order, maintain original relative order + return updatedThemes.indexOf(a) - updatedThemes.indexOf(b); + }); + const newActiveTheme = activeTheme; Object.keys(newActiveTheme).forEach((group) => { // check whether the activeTheme is still belong to the group - if (updatedThemes.findIndex((theme) => theme.id === activeTheme?.[group] && theme.group === group) < 0) { + if (finalThemes.findIndex((theme) => theme.id === activeTheme?.[group] && theme.group === group) < 0) { delete newActiveTheme[group]; } }); - dispatch.tokenState.replaceThemes(updatedThemes); + dispatch.tokenState.replaceThemes(finalThemes); }, [dispatch.tokenState, activeTheme]); + // Helper to check if a theme group is extended (child theme) + const isExtendedGroup = useCallback((groupName: string) => { + // Check if group name contains "/" (hierarchical format like "Parent/Extended") + if (groupName.includes('/')) { + return true; + } + // Also check if any theme in this group has $figmaParentThemeId + const themesInGroup = themes.filter(t => t.group === groupName); + return themesInGroup.some(t => t.$figmaParentThemeId); + }, [themes]); + const handleCheckReorder = React.useCallback(( order: ItemData[], value: typeof treeItems[number], @@ -197,28 +385,35 @@ export const ManageThemesModal: React.FC {!themeEditorOpen && ( - + <> + + )} {themeEditorOpen && ( <> - {typeof themeEditorOpen === 'string' && ( - - )} + {typeof themeEditorOpen === 'string' && (() => { + const currentTheme = themes.find((t) => t.id === themeEditorOpen); + const isExtendedTheme = currentTheme?.$figmaIsExtension; + + return !isExtendedTheme ? ( + + ) : null; + })()} - + )} {themeEditorOpen && ( <> - {typeof themeEditorOpen === 'string' && (() => { - const currentTheme = themes.find((t) => t.id === themeEditorOpen); - const isExtendedTheme = currentTheme?.$figmaIsExtension; - - return !isExtendedTheme ? ( - - ) : null; - })()} + {isEditingNonExtendedTheme && ( + + )}