Skip to content

Commit 193ef2c

Browse files
author
Sebastian Thulin
committed
fix: add scoping
1 parent f8b6e4c commit 193ef2c

File tree

1 file changed

+163
-60
lines changed

1 file changed

+163
-60
lines changed

source/design-builder/index.ts

Lines changed: 163 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ interface ComponentTokenDefinition {
3636
}
3737

3838
type ComponentTokenData = Record<string, ComponentTokenDefinition>;
39-
type ComponentOverrides = Record<string, Record<string, string>>;
39+
type ComponentVariableOverrides = Record<string, string>;
40+
type ComponentOverrides = Record<string, ComponentVariableOverrides>;
41+
type ScopedComponentOverrides = Record<string, ComponentOverrides>;
4042

4143
declare global {
4244
interface Window {
@@ -46,6 +48,7 @@ declare global {
4648
}
4749

4850
const COMPONENT_STORAGE_KEY = 'design-tokens-component-overrides';
51+
const GLOBAL_SCOPE_KEY = '__global__';
4952

5053
class ComponentStorageAdapter {
5154
private key: string;
@@ -54,7 +57,7 @@ class ComponentStorageAdapter {
5457
this.key = key;
5558
}
5659

57-
public load(): ComponentOverrides {
60+
public load(): ScopedComponentOverrides {
5861
try {
5962
const raw = localStorage.getItem(this.key);
6063
if (!raw) return {};
@@ -63,33 +66,72 @@ class ComponentStorageAdapter {
6366
return {};
6467
}
6568

66-
const result: ComponentOverrides = {};
67-
for (const [componentName, values] of Object.entries(parsed)) {
68-
if (!values || typeof values !== 'object' || Array.isArray(values)) continue;
69-
70-
const componentOverrides: Record<string, string> = {};
71-
for (const [variable, value] of Object.entries(values)) {
72-
if (typeof value === 'string' && value.trim() !== '') {
73-
componentOverrides[variable] = value;
74-
}
69+
if (this.isLegacyComponentOverrides(parsed as Record<string, unknown>)) {
70+
const legacy = this.normalizeComponentOverrides(parsed as Record<string, unknown>);
71+
if (Object.keys(legacy).length === 0) {
72+
return {};
7573
}
7674

77-
if (Object.keys(componentOverrides).length > 0) {
78-
result[componentName] = componentOverrides;
79-
}
75+
return {
76+
[GLOBAL_SCOPE_KEY]: legacy,
77+
};
8078
}
8179

82-
return result;
80+
return this.normalizeScopedOverrides(parsed as Record<string, unknown>);
8381
} catch {
8482
return {};
8583
}
8684
}
8785

88-
public save(overrides: ComponentOverrides): void {
86+
public save(overrides: ScopedComponentOverrides): void {
87+
const cleaned = this.normalizeScopedOverrides(overrides as Record<string, unknown>);
88+
89+
if (Object.keys(cleaned).length === 0) {
90+
localStorage.removeItem(this.key);
91+
return;
92+
}
93+
94+
localStorage.setItem(this.key, JSON.stringify(cleaned));
95+
}
96+
97+
private isLegacyComponentOverrides(input: Record<string, unknown>): boolean {
98+
const values = Object.values(input);
99+
if (values.length === 0) {
100+
return false;
101+
}
102+
103+
return values.every((value) => {
104+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
105+
return false;
106+
}
107+
108+
const variableValues = Object.values(value as Record<string, unknown>);
109+
return variableValues.every((entry) => typeof entry === 'string');
110+
});
111+
}
112+
113+
private normalizeScopedOverrides(input: Record<string, unknown>): ScopedComponentOverrides {
114+
const result: ScopedComponentOverrides = {};
115+
116+
for (const [scopeKey, scopeValue] of Object.entries(input)) {
117+
if (!scopeValue || typeof scopeValue !== 'object' || Array.isArray(scopeValue)) continue;
118+
119+
const componentOverrides = this.normalizeComponentOverrides(scopeValue as Record<string, unknown>);
120+
if (Object.keys(componentOverrides).length > 0) {
121+
result[scopeKey] = componentOverrides;
122+
}
123+
}
124+
125+
return result;
126+
}
127+
128+
private normalizeComponentOverrides(input: Record<string, unknown>): ComponentOverrides {
89129
const cleaned: ComponentOverrides = {};
90130

91-
for (const [componentName, values] of Object.entries(overrides)) {
92-
const filtered: Record<string, string> = {};
131+
for (const [componentName, values] of Object.entries(input)) {
132+
if (!values || typeof values !== 'object' || Array.isArray(values)) continue;
133+
134+
const filtered: ComponentVariableOverrides = {};
93135

94136
for (const [variable, value] of Object.entries(values)) {
95137
if (typeof value === 'string' && value.trim() !== '') {
@@ -102,23 +144,19 @@ class ComponentStorageAdapter {
102144
}
103145
}
104146

105-
if (Object.keys(cleaned).length === 0) {
106-
localStorage.removeItem(this.key);
107-
return;
108-
}
109-
110-
localStorage.setItem(this.key, JSON.stringify(cleaned));
147+
return cleaned;
111148
}
112149
}
113150

114151
class ComponentCustomizationTool {
115152
private componentData: ComponentTokenData;
116153
private tokenLibrary: TokenData;
117154
private storage: ComponentStorageAdapter;
118-
private overrides: ComponentOverrides;
155+
private overrides: ScopedComponentOverrides;
119156
private elementsByComponent = new Map<string, HTMLElement[]>();
120157
private editableComponents = new Set<string>();
121158
private activeComponent: string | null = null;
159+
private activeScopeKey: string = GLOBAL_SCOPE_KEY;
122160
private root: HTMLElement | null = null;
123161
private controlsContainer: HTMLElement | null = null;
124162
private componentSelect: HTMLSelectElement | null = null;
@@ -157,9 +195,20 @@ class ComponentCustomizationTool {
157195
private pruneUnknownOverrides(): void {
158196
let hasChanges = false;
159197

160-
for (const componentName of Object.keys(this.overrides)) {
161-
if (!this.elementsByComponent.has(componentName) || !this.editableComponents.has(componentName)) {
162-
delete this.overrides[componentName];
198+
for (const [scopeKey, scopeOverrides] of Object.entries(this.overrides)) {
199+
for (const componentName of Object.keys(scopeOverrides)) {
200+
const isMissingComponent =
201+
!this.elementsByComponent.has(componentName) || !this.editableComponents.has(componentName);
202+
const hasContextTarget = this.getElementsForContext(componentName, scopeKey).length > 0;
203+
204+
if (isMissingComponent || !hasContextTarget) {
205+
delete this.overrides[scopeKey][componentName];
206+
hasChanges = true;
207+
}
208+
}
209+
210+
if (Object.keys(this.overrides[scopeKey]).length === 0) {
211+
delete this.overrides[scopeKey];
163212
hasChanges = true;
164213
}
165214
}
@@ -170,9 +219,11 @@ class ComponentCustomizationTool {
170219
}
171220

172221
private applySavedOverrides(): void {
173-
for (const [componentName, componentOverrides] of Object.entries(this.overrides)) {
174-
for (const [variable, value] of Object.entries(componentOverrides)) {
175-
this.applyVariable(componentName, variable, value);
222+
for (const [scopeKey, scopeOverrides] of Object.entries(this.overrides)) {
223+
for (const [componentName, componentOverrides] of Object.entries(scopeOverrides)) {
224+
for (const [variable, value] of Object.entries(componentOverrides)) {
225+
this.applyVariable(componentName, scopeKey, variable, value);
226+
}
176227
}
177228
}
178229
}
@@ -197,7 +248,10 @@ class ComponentCustomizationTool {
197248
if (!isEditable) continue;
198249

199250
element.classList.add('db-component-target');
200-
element.dataset.customizeTooltip = `Customize ${this.getComponentLabel(componentName)}`;
251+
const scopeLabel = this.getScopeLabel(this.getScopeKeyForElement(element));
252+
element.dataset.customizeTooltip = scopeLabel
253+
? `Customize ${this.getComponentLabel(componentName)} (${scopeLabel})`
254+
: `Customize ${this.getComponentLabel(componentName)}`;
201255

202256
const links = element.querySelectorAll<HTMLAnchorElement>('a[href]');
203257
for (const link of links) {
@@ -221,7 +275,8 @@ class ComponentCustomizationTool {
221275
if (!this.root) return;
222276

223277
this.activeComponent = componentName;
224-
this.setActiveTarget(componentName, element);
278+
this.activeScopeKey = this.getScopeKeyForElement(element);
279+
this.setActiveTarget(componentName, this.activeScopeKey, element);
225280
if (this.componentSelect) {
226281
this.componentSelect.value = componentName;
227282
}
@@ -283,7 +338,7 @@ class ComponentCustomizationTool {
283338
this.componentSelect.addEventListener('change', () => {
284339
this.activeComponent = this.componentSelect?.value || null;
285340
if (this.activeComponent) {
286-
this.setActiveTarget(this.activeComponent);
341+
this.setActiveTarget(this.activeComponent, this.activeScopeKey);
287342
}
288343
this.renderControls();
289344
});
@@ -300,26 +355,50 @@ class ComponentCustomizationTool {
300355

301356
this.renderControls();
302357
if (this.activeComponent) {
303-
this.setActiveTarget(this.activeComponent);
358+
this.setActiveTarget(this.activeComponent, this.activeScopeKey);
304359
}
305360
}
306361

307-
private setActiveTarget(componentName: string, preferredElement?: HTMLElement): void {
362+
private setActiveTarget(componentName: string, scopeKey: string, preferredElement?: HTMLElement): void {
308363
if (this.activeTargetElement) {
309364
this.activeTargetElement.classList.remove('db-component-target--active');
310365
}
311366

312-
const candidates = this.elementsByComponent.get(componentName) || [];
313-
const target = preferredElement || candidates[0] || null;
367+
const candidates = this.getElementsForContext(componentName, scopeKey);
368+
const fallbackCandidates = this.elementsByComponent.get(componentName) || [];
369+
const target = preferredElement || candidates[0] || fallbackCandidates[0] || null;
314370
if (!target) {
315371
this.activeTargetElement = null;
316372
return;
317373
}
318374

375+
this.activeScopeKey = this.getScopeKeyForElement(target);
319376
target.classList.add('db-component-target--active');
320377
this.activeTargetElement = target;
321378
}
322379

380+
private getScopeKeyForElement(element: HTMLElement): string {
381+
const scope = element.closest<HTMLElement>('[data-scope]')?.dataset.scope?.trim();
382+
if (!scope) {
383+
return GLOBAL_SCOPE_KEY;
384+
}
385+
386+
return `scope:${scope}`;
387+
}
388+
389+
private getScopeLabel(scopeKey: string): string {
390+
if (scopeKey === GLOBAL_SCOPE_KEY) {
391+
return '';
392+
}
393+
394+
return scopeKey.replace(/^scope:/, '');
395+
}
396+
397+
private getElementsForContext(componentName: string, scopeKey: string): HTMLElement[] {
398+
const elements = this.elementsByComponent.get(componentName) || [];
399+
return elements.filter((element) => this.getScopeKeyForElement(element) === scopeKey);
400+
}
401+
323402
private getSortedComponentNames(): string[] {
324403
return Array.from(this.editableComponents).sort((a, b) => a.localeCompare(b));
325404
}
@@ -361,9 +440,10 @@ class ComponentCustomizationTool {
361440
body.className = 'db-category__body';
362441

363442
for (const setting of category.settings) {
364-
const currentValue = this.overrides[this.activeComponent]?.[setting.variable] || setting.default;
443+
const currentValue =
444+
this.overrides[this.activeScopeKey]?.[this.activeComponent]?.[setting.variable] || setting.default;
365445
const control = createControl(setting, currentValue, (variable, value) => {
366-
this.handleChange(this.activeComponent as string, variable, value, setting.default);
446+
this.handleChange(this.activeComponent as string, this.activeScopeKey, variable, value, setting.default);
367447
});
368448
body.appendChild(control);
369449
}
@@ -406,51 +486,72 @@ class ComponentCustomizationTool {
406486
return categories;
407487
}
408488

409-
private handleChange(componentName: string, variable: string, value: string, defaultValue: string): void {
410-
if (!this.overrides[componentName]) {
411-
this.overrides[componentName] = {};
489+
private handleChange(
490+
componentName: string,
491+
scopeKey: string,
492+
variable: string,
493+
value: string,
494+
defaultValue: string,
495+
): void {
496+
if (!this.overrides[scopeKey]) {
497+
this.overrides[scopeKey] = {};
498+
}
499+
500+
if (!this.overrides[scopeKey][componentName]) {
501+
this.overrides[scopeKey][componentName] = {};
412502
}
413503

414504
if (!value || value === defaultValue) {
415-
delete this.overrides[componentName][variable];
416-
this.removeVariable(componentName, variable);
505+
delete this.overrides[scopeKey][componentName][variable];
506+
this.removeVariable(componentName, scopeKey, variable);
417507
} else {
418-
this.overrides[componentName][variable] = value;
419-
this.applyVariable(componentName, variable, value);
508+
this.overrides[scopeKey][componentName][variable] = value;
509+
this.applyVariable(componentName, scopeKey, variable, value);
420510
}
421511

422-
if (Object.keys(this.overrides[componentName]).length === 0) {
423-
delete this.overrides[componentName];
512+
if (Object.keys(this.overrides[scopeKey][componentName]).length === 0) {
513+
delete this.overrides[scopeKey][componentName];
514+
}
515+
516+
if (Object.keys(this.overrides[scopeKey]).length === 0) {
517+
delete this.overrides[scopeKey];
424518
}
425519

426520
this.storage.save(this.overrides);
427521
}
428522

429-
private applyVariable(componentName: string, variable: string, value: string): void {
430-
const elements = this.elementsByComponent.get(componentName) || [];
523+
private applyVariable(componentName: string, scopeKey: string, variable: string, value: string): void {
524+
const elements = this.getElementsForContext(componentName, scopeKey);
431525
for (const element of elements) {
432526
element.style.setProperty(variable, value);
433527
}
434528
}
435529

436-
private removeVariable(componentName: string, variable: string): void {
437-
const elements = this.elementsByComponent.get(componentName) || [];
530+
private removeVariable(componentName: string, scopeKey: string, variable: string): void {
531+
const elements = this.getElementsForContext(componentName, scopeKey);
438532
for (const element of elements) {
439533
element.style.removeProperty(variable);
440534
}
441535
}
442536

443537
private resetComponent(componentName: string): void {
444-
if (!confirm(`Reset all overrides for ${this.getComponentLabel(componentName)}?`)) {
538+
const scopeLabel = this.getScopeLabel(this.activeScopeKey);
539+
const labelSuffix = scopeLabel ? ` in scope "${scopeLabel}"` : '';
540+
if (!confirm(`Reset all overrides for ${this.getComponentLabel(componentName)}${labelSuffix}?`)) {
445541
return;
446542
}
447543

448-
const variables = Object.keys(this.overrides[componentName] || {});
544+
const variables = Object.keys(this.overrides[this.activeScopeKey]?.[componentName] || {});
449545
for (const variable of variables) {
450-
this.removeVariable(componentName, variable);
546+
this.removeVariable(componentName, this.activeScopeKey, variable);
451547
}
452548

453-
delete this.overrides[componentName];
549+
if (this.overrides[this.activeScopeKey]) {
550+
delete this.overrides[this.activeScopeKey][componentName];
551+
if (Object.keys(this.overrides[this.activeScopeKey]).length === 0) {
552+
delete this.overrides[this.activeScopeKey];
553+
}
554+
}
454555
this.storage.save(this.overrides);
455556
this.renderControls();
456557
}
@@ -460,9 +561,11 @@ class ComponentCustomizationTool {
460561
return;
461562
}
462563

463-
for (const [componentName, componentOverrides] of Object.entries(this.overrides)) {
464-
for (const variable of Object.keys(componentOverrides)) {
465-
this.removeVariable(componentName, variable);
564+
for (const [scopeKey, scopeOverrides] of Object.entries(this.overrides)) {
565+
for (const [componentName, componentOverrides] of Object.entries(scopeOverrides)) {
566+
for (const variable of Object.keys(componentOverrides)) {
567+
this.removeVariable(componentName, scopeKey, variable);
568+
}
466569
}
467570
}
468571

0 commit comments

Comments
 (0)