feat: Page Editor UX Phase 1 - live preview, hover mapping, visual layout picker
- PreviewHighlightService + appHighlightSource directive: shared hover/focus bridge between editor fields and the schematic live preview. - BuilderLivePreviewComponent: in-page schematic homepage render (header/hero/blocks/footer) reading the same bootstrap the sections mutate, highlighting the area matching the active field. - VisualLayoutPickerComponent: card-based layout picker (ControlValueAccessor, same shape as app-select) replacing the raw layout <select> in homepage-section. - app-form-field gains optional usedBy/usedByLabel inputs for the "Where is this used?" helper text, wired into aria-describedby. - Wired homepage/branding/theme sections with highlight sources + usedBy hints; live preview panel shown in project-editor-page for those three sections. - All new copy added as translation keys (en/ru/hy).
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
@if (bootstrap(); as bootstrap) {
|
||||
<div class="builder-live-preview" role="img" [attr.aria-label]="'builder.livePreviewTitle' | translate">
|
||||
<header
|
||||
class="builder-live-preview__header"
|
||||
[class.builder-live-preview__header--active]="isActive('logo') || isActive('title')"
|
||||
>
|
||||
@if (bootstrap.branding.logoUrl) {
|
||||
<img class="builder-live-preview__logo" [class.builder-live-preview__logo--active]="isActive('logo')" [src]="bootstrap.branding.logoUrl" alt="" />
|
||||
} @else {
|
||||
<span class="builder-live-preview__logo builder-live-preview__logo--placeholder" [class.builder-live-preview__logo--active]="isActive('logo')"></span>
|
||||
}
|
||||
<span class="builder-live-preview__title" [class.builder-live-preview__title--active]="isActive('title')">
|
||||
{{ bootstrap.seo.default.title || bootstrap.branding.brandName }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="builder-live-preview__hero"
|
||||
[class.builder-live-preview__hero--active]="isActive('background') || isActive('image')"
|
||||
[style.background]="bootstrap.theme.palette.backgroundSecondary"
|
||||
>
|
||||
<span class="builder-live-preview__hero-image" [class.builder-live-preview__hero-image--active]="isActive('image')" aria-hidden="true">
|
||||
<app-icon name="image" [size]="18" />
|
||||
</span>
|
||||
<span
|
||||
class="builder-live-preview__button"
|
||||
[class.builder-live-preview__button--active]="isActive('button') || isActive('color')"
|
||||
[style.background]="bootstrap.theme.palette.primary"
|
||||
>
|
||||
{{ 'builder.livePreviewButtonLabel' | translate }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="builder-live-preview__blocks"
|
||||
[class.builder-live-preview__blocks--active]="isActive('layout') || isActive('spacing')"
|
||||
>
|
||||
@for (block of blocks(); track block.id) {
|
||||
<div
|
||||
class="builder-live-preview__block"
|
||||
[class.builder-live-preview__block--hidden]="!block.visible"
|
||||
[class.builder-live-preview__block--active]="isActive('block:' + block.id)"
|
||||
[attr.data-layout]="block.strategy"
|
||||
>
|
||||
<app-icon [name]="block.icon" [size]="14" />
|
||||
</div>
|
||||
} @empty {
|
||||
<p class="builder-live-preview__empty">{{ 'builder.livePreviewEmpty' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<footer class="builder-live-preview__footer"></footer>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="builder-live-preview__empty">{{ 'builder.livePreviewEmpty' | translate }}</p>
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
.builder-live-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs, 0.25rem);
|
||||
border: 1px solid var(--border-color, #e4e4e7);
|
||||
border-radius: var(--radius-lg, 10px);
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary, #fff);
|
||||
}
|
||||
|
||||
.builder-live-preview__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 0.5rem);
|
||||
padding: var(--space-sm, 0.5rem) var(--space-md, 1rem);
|
||||
border-bottom: 1px solid var(--border-color, #e4e4e7);
|
||||
transition: box-shadow var(--transition-fast, 120ms ease);
|
||||
}
|
||||
|
||||
.builder-live-preview__header--active {
|
||||
box-shadow: inset 0 0 0 2px var(--primary-color, #2f6e5d);
|
||||
}
|
||||
|
||||
.builder-live-preview__logo {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
object-fit: contain;
|
||||
transition: outline-color var(--transition-fast, 120ms ease);
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.builder-live-preview__logo--placeholder {
|
||||
background: var(--bg-secondary, #f4f4f5);
|
||||
}
|
||||
|
||||
.builder-live-preview__logo--active {
|
||||
outline-color: var(--primary-color, #2f6e5d);
|
||||
}
|
||||
|
||||
.builder-live-preview__title {
|
||||
font-size: var(--font-size-sm, 0.8125rem);
|
||||
font-weight: var(--font-weight-semibold, 600);
|
||||
color: var(--text-primary, #1f322d);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
transition: box-shadow var(--transition-fast, 120ms ease);
|
||||
}
|
||||
|
||||
.builder-live-preview__title--active {
|
||||
box-shadow: inset 0 0 0 2px var(--primary-color, #2f6e5d);
|
||||
padding: 0 var(--space-xs, 0.25rem);
|
||||
}
|
||||
|
||||
.builder-live-preview__hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm, 0.5rem);
|
||||
padding: var(--space-lg, 1.5rem) var(--space-md, 1rem);
|
||||
transition: box-shadow var(--transition-fast, 120ms ease);
|
||||
}
|
||||
|
||||
.builder-live-preview__hero--active {
|
||||
box-shadow: inset 0 0 0 2px var(--primary-color, #2f6e5d);
|
||||
}
|
||||
|
||||
.builder-live-preview__hero-image {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
background: var(--bg-primary, #fff);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.builder-live-preview__hero-image--active {
|
||||
outline-color: var(--primary-color, #2f6e5d);
|
||||
}
|
||||
|
||||
.builder-live-preview__button {
|
||||
padding: var(--space-xs, 0.25rem) var(--space-md, 1rem);
|
||||
border-radius: var(--radius-md, 6px);
|
||||
color: #fff;
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
font-weight: var(--font-weight-semibold, 600);
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
transition: outline-color var(--transition-fast, 120ms ease);
|
||||
}
|
||||
|
||||
.builder-live-preview__button--active {
|
||||
outline-color: var(--primary-color, #2f6e5d);
|
||||
}
|
||||
|
||||
.builder-live-preview__blocks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs, 0.25rem);
|
||||
padding: var(--space-sm, 0.5rem) var(--space-md, 1rem);
|
||||
border-radius: var(--radius-md, 6px);
|
||||
transition: box-shadow var(--transition-fast, 120ms ease);
|
||||
}
|
||||
|
||||
.builder-live-preview__blocks--active {
|
||||
box-shadow: inset 0 0 0 2px var(--primary-color, #2f6e5d);
|
||||
}
|
||||
|
||||
.builder-live-preview__block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: var(--bg-secondary, #f4f4f5);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
transition: outline-color var(--transition-fast, 120ms ease), opacity var(--transition-fast, 120ms ease);
|
||||
}
|
||||
|
||||
.builder-live-preview__block--hidden {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.builder-live-preview__block--active {
|
||||
outline-color: var(--primary-color, #2f6e5d);
|
||||
}
|
||||
|
||||
.builder-live-preview__footer {
|
||||
height: 16px;
|
||||
background: var(--bg-secondary, #f4f4f5);
|
||||
}
|
||||
|
||||
.builder-live-preview__empty {
|
||||
margin: 0;
|
||||
padding: var(--space-md, 1rem);
|
||||
font-size: var(--font-size-sm, 0.8125rem);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.builder-live-preview__header,
|
||||
.builder-live-preview__logo,
|
||||
.builder-live-preview__title,
|
||||
.builder-live-preview__hero,
|
||||
.builder-live-preview__hero-image,
|
||||
.builder-live-preview__button,
|
||||
.builder-live-preview__blocks,
|
||||
.builder-live-preview__block {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||
import { ProjectEditorFacade } from '../../facade/project-editor.facade';
|
||||
import { PreviewHighlightService } from '../../../../shared/services/preview-highlight.service';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { IconComponent } from '../../../../shared/ui/icon/icon.component';
|
||||
import { AppIconName } from '../../../../shared/ui/icon/icon-registry';
|
||||
|
||||
const BLOCK_ICON_BY_TYPE: Record<string, AppIconName> = {
|
||||
hero: 'image',
|
||||
categories: 'layoutGrid',
|
||||
'product-collection': 'package',
|
||||
'product-carousel': 'images',
|
||||
'recently-viewed': 'history',
|
||||
banner: 'megaphone',
|
||||
partners: 'verified',
|
||||
html: 'code',
|
||||
};
|
||||
|
||||
/**
|
||||
* Schematic (not pixel-accurate) representation of the storefront homepage,
|
||||
* built from the same bootstrap the section editors mutate. It exists so a
|
||||
* field can highlight "the area it affects" (§ PreviewHighlightService)
|
||||
* without re-navigating away like ProjectEditorPreviewService.preview() does.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-builder-live-preview',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe, IconComponent],
|
||||
templateUrl: './builder-live-preview.component.html',
|
||||
styleUrl: './builder-live-preview.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class BuilderLivePreviewComponent {
|
||||
private readonly facade = inject(ProjectEditorFacade);
|
||||
private readonly highlight = inject(PreviewHighlightService);
|
||||
|
||||
readonly bootstrap = this.facade.bootstrap;
|
||||
readonly homepagePage = this.facade.homepagePage;
|
||||
readonly activeHighlight = this.highlight.activeId;
|
||||
|
||||
readonly blocks = computed(() =>
|
||||
[...(this.homepagePage()?.sections ?? [])]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(section => ({
|
||||
id: section.id,
|
||||
type: section.type,
|
||||
strategy: section.layout?.strategy ?? 'stack',
|
||||
columns: section.layout?.columns ?? 1,
|
||||
visible: section.visible !== false,
|
||||
icon: BLOCK_ICON_BY_TYPE[section.type] ?? 'stop',
|
||||
}))
|
||||
);
|
||||
|
||||
isActive(id: string): boolean {
|
||||
return this.activeHighlight() === id;
|
||||
}
|
||||
}
|
||||
@@ -82,27 +82,36 @@
|
||||
<p>{{ 'common.loading' | translate }}</p>
|
||||
</section>
|
||||
} @else {
|
||||
<section class="project-editor-stack">
|
||||
@if (canResetActiveSection()) {
|
||||
<div class="project-editor-section-actions">
|
||||
<app-button variant="danger" size="sm" (click)="resetActiveSection()">{{ 'builder.resetSection' | translate }}</app-button>
|
||||
</div>
|
||||
<div class="project-editor-work-area" [class.project-editor-work-area--with-preview]="showLivePreview()">
|
||||
<section class="project-editor-stack">
|
||||
@if (canResetActiveSection()) {
|
||||
<div class="project-editor-section-actions">
|
||||
<app-button variant="danger" size="sm" (click)="resetActiveSection()">{{ 'builder.resetSection' | translate }}</app-button>
|
||||
</div>
|
||||
}
|
||||
@switch (activeSection()) {
|
||||
@case ('general') { <app-project-editor-general-section /> }
|
||||
@case ('branding') { <app-project-editor-branding-section /> }
|
||||
@case ('theme') { <app-project-editor-theme-section /> }
|
||||
@case ('header') { <app-project-editor-header-section /> }
|
||||
@case ('footer') { <app-project-editor-footer-section /> }
|
||||
@case ('homepage') { <app-project-editor-homepage-section /> }
|
||||
@case ('widgets') { <app-project-editor-widgets-section /> }
|
||||
@case ('static-pages') { <app-static-pages-editor /> }
|
||||
@case ('features') { <app-project-editor-features-section /> }
|
||||
@case ('languages') { <app-project-editor-languages-section /> }
|
||||
@case ('navigation') { <app-project-editor-navigation-section /> }
|
||||
@case ('preview') { <app-project-editor-preview-section /> }
|
||||
}
|
||||
</section>
|
||||
@if (showLivePreview()) {
|
||||
<aside class="project-editor-live-preview" aria-label="{{ 'builder.livePreviewTitle' | translate }}">
|
||||
<h2 class="project-editor-live-preview__title">{{ 'builder.livePreviewTitle' | translate }}</h2>
|
||||
<p class="project-editor-live-preview__hint">{{ 'builder.livePreviewHint' | translate }}</p>
|
||||
<app-builder-live-preview />
|
||||
</aside>
|
||||
}
|
||||
@switch (activeSection()) {
|
||||
@case ('general') { <app-project-editor-general-section /> }
|
||||
@case ('branding') { <app-project-editor-branding-section /> }
|
||||
@case ('theme') { <app-project-editor-theme-section /> }
|
||||
@case ('header') { <app-project-editor-header-section /> }
|
||||
@case ('footer') { <app-project-editor-footer-section /> }
|
||||
@case ('homepage') { <app-project-editor-homepage-section /> }
|
||||
@case ('widgets') { <app-project-editor-widgets-section /> }
|
||||
@case ('static-pages') { <app-static-pages-editor /> }
|
||||
@case ('features') { <app-project-editor-features-section /> }
|
||||
@case ('languages') { <app-project-editor-languages-section /> }
|
||||
@case ('navigation') { <app-project-editor-navigation-section /> }
|
||||
@case ('preview') { <app-project-editor-preview-section /> }
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
<app-project-editor-save-bar />
|
||||
}
|
||||
</main>
|
||||
|
||||
@@ -277,6 +277,44 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.project-editor-work-area {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.project-editor-work-area--with-preview {
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.project-editor-work-area--with-preview {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.project-editor-live-preview {
|
||||
position: sticky;
|
||||
top: var(--space-md, 1rem);
|
||||
padding: var(--space-md, 1rem);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg, 10px);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.project-editor-live-preview__title {
|
||||
margin: 0 0 var(--space-xs, 0.25rem);
|
||||
font-size: var(--font-size-base, 0.875rem);
|
||||
font-weight: var(--font-weight-semibold, 600);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.project-editor-live-preview__hint {
|
||||
margin: 0 0 var(--space-sm, 0.5rem);
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.project-editor-empty {
|
||||
min-height: 240px;
|
||||
display: grid;
|
||||
|
||||
@@ -25,6 +25,10 @@ import { StaticPagesEditorComponent } from '../../content-management/components/
|
||||
import { ProjectEditorSaveBarComponent } from '../components/save-bar/project-editor-save-bar.component';
|
||||
import { EDITOR_SECTION_BOOTSTRAP_KEYS, ProjectEditorSectionId } from '../models/project-editor.model';
|
||||
import { BUILDER_GROUPS, SECTION_TO_GROUP } from '../builder/builder-groups.model';
|
||||
import { BuilderLivePreviewComponent } from '../components/live-preview/builder-live-preview.component';
|
||||
|
||||
/** Sections that currently expose highlight-mapped fields (see HighlightSourceDirective usages) - showing the panel elsewhere would just be an inert, unhighlightable diagram. */
|
||||
const SECTIONS_WITH_LIVE_PREVIEW: ProjectEditorSectionId[] = ['branding', 'theme', 'homepage'];
|
||||
|
||||
const KNOWN_SECTIONS: ProjectEditorSectionId[] = [
|
||||
'general', 'branding', 'theme', 'header', 'footer', 'homepage', 'widgets', 'static-pages', 'features', 'languages', 'navigation', 'preview'
|
||||
@@ -68,6 +72,7 @@ const SECTION_LABEL_KEYS: Record<ProjectEditorSectionId, string> = {
|
||||
IconComponent,
|
||||
ButtonComponent,
|
||||
ConfirmDialogComponent,
|
||||
BuilderLivePreviewComponent,
|
||||
],
|
||||
templateUrl: './project-editor-page.component.html',
|
||||
styleUrls: ['./project-editor-page.component.scss'],
|
||||
@@ -99,6 +104,8 @@ export class ProjectEditorPageComponent {
|
||||
|
||||
readonly isModifiedActiveSection = computed(() => this.facade.modifiedSections().has(this.activeSection()));
|
||||
|
||||
readonly showLivePreview = computed(() => SECTIONS_WITH_LIVE_PREVIEW.includes(this.activeSection()));
|
||||
|
||||
readonly overviewRoute = computed(() => ['/', this.languageService.currentLanguage(), 'edit']);
|
||||
|
||||
/** Persistent escape hatch back to the admin dashboard - section editors previously had no route to the backoffice at all. */
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
<app-brand-overview />
|
||||
<app-section-card [title]="'builder.branding' | translate">
|
||||
<div class="editor-grid two">
|
||||
<app-form-field [label]="'builder.logo' | translate" [hint]="'builder.logoDesc' | translate" [error]="fieldError('branding.logoUrl')">
|
||||
<app-form-field
|
||||
appHighlightSource="logo"
|
||||
[label]="'builder.logo' | translate"
|
||||
[hint]="'builder.logoDesc' | translate"
|
||||
[error]="fieldError('branding.logoUrl')"
|
||||
[usedBy]="[('builder.usedByHeader' | translate), ('builder.usedByFooter' | translate)]"
|
||||
[usedByLabel]="'builder.usedByLabel' | translate"
|
||||
>
|
||||
<app-image-field [value]="bootstrap.branding.logoUrl" (valueChange)="updateField('logoUrl', $event)" />
|
||||
</app-form-field>
|
||||
<app-form-field [label]="'builder.smallLogo' | translate" [hint]="'builder.smallLogoDesc' | translate">
|
||||
@@ -14,7 +21,14 @@
|
||||
<app-form-field [label]="'builder.socialImage' | translate" [hint]="'builder.socialImageDesc' | translate">
|
||||
<app-image-field [value]="bootstrap.branding.socialImageUrl || ''" (valueChange)="updateField('socialImageUrl', $event)" />
|
||||
</app-form-field>
|
||||
<app-form-field class="full" [label]="'builder.marketplaceTitle' | translate" [hint]="'builder.marketplaceTitleDesc' | translate">
|
||||
<app-form-field
|
||||
class="full"
|
||||
appHighlightSource="title"
|
||||
[label]="'builder.marketplaceTitle' | translate"
|
||||
[hint]="'builder.marketplaceTitleDesc' | translate"
|
||||
[usedBy]="[('builder.usedByHeader' | translate), ('builder.usedBySeo' | translate)]"
|
||||
[usedByLabel]="'builder.usedByLabel' | translate"
|
||||
>
|
||||
<app-input [ngModel]="bootstrap.seo.default.title" (ngModelChange)="updateMarketplaceTitle($event)" />
|
||||
</app-form-field>
|
||||
</div>
|
||||
|
||||
@@ -9,13 +9,14 @@ import { ButtonComponent } from '../../../shared/ui/button/button.component';
|
||||
import { ImageFieldComponent } from '../../../shared/ui/image-field/image-field.component';
|
||||
import { SectionCardComponent } from '../../../shared/ui/section-card/section-card.component';
|
||||
import { BrandOverviewComponent } from './brand/brand-overview.component';
|
||||
import { HighlightSourceDirective } from '../../../shared/ui/highlight-source/highlight-source.directive';
|
||||
|
||||
type BrandingImageField = 'logoUrl' | 'logoCompactUrl' | 'faviconUrl' | 'socialImageUrl';
|
||||
|
||||
@Component({
|
||||
selector: 'app-project-editor-branding-section',
|
||||
standalone: true,
|
||||
imports: [FormsModule, TranslatePipe, InputComponent, FormFieldComponent, ButtonComponent, ImageFieldComponent, SectionCardComponent, BrandOverviewComponent],
|
||||
imports: [FormsModule, TranslatePipe, InputComponent, FormFieldComponent, ButtonComponent, ImageFieldComponent, SectionCardComponent, BrandOverviewComponent, HighlightSourceDirective],
|
||||
templateUrl: './branding-section.component.html',
|
||||
styleUrls: ['./section.shared.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<app-icon name="grip" [size]="16" />
|
||||
</div>
|
||||
|
||||
<div class="block-card__preview">
|
||||
<div class="block-card__preview" [appHighlightSource]="'block:' + section.id">
|
||||
<app-icon [name]="blockIcon(section.type)" [size]="20" />
|
||||
</div>
|
||||
|
||||
@@ -31,20 +31,28 @@
|
||||
<p class="block-card__desc">{{ blockDescription(section.type) }}</p>
|
||||
|
||||
<div class="editor-grid two compact">
|
||||
<label>
|
||||
<span>{{ 'builder.layoutStrategyLabel' | translate }}</span>
|
||||
<small class="field-desc">{{ layoutStrategyDescKey(section.layout?.strategy || 'stack') | translate }}</small>
|
||||
<select [ngModel]="section.layout?.strategy || 'stack'" (ngModelChange)="updateLayout(section.id, 'strategy', $event)">
|
||||
@for (option of layoutStrategyOptions; track option.value) {
|
||||
<option [value]="option.value">{{ option.labelKey | translate }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ 'builder.columns' | translate }}</span>
|
||||
<small class="field-desc">{{ 'builder.sectionColumnsDesc' | translate }}</small>
|
||||
<app-form-field
|
||||
appHighlightSource="layout"
|
||||
[label]="'builder.layoutStrategyLabel' | translate"
|
||||
[usedBy]="[blockLabel(section.type)]"
|
||||
[usedByLabel]="'builder.usedByLabel' | translate"
|
||||
>
|
||||
<app-visual-layout-picker
|
||||
[options]="layoutStrategyPickerOptions"
|
||||
[ariaLabel]="'builder.layoutStrategyLabel' | translate"
|
||||
[ngModel]="section.layout?.strategy || 'stack'"
|
||||
(ngModelChange)="updateLayout(section.id, 'strategy', $event)"
|
||||
/>
|
||||
</app-form-field>
|
||||
<app-form-field
|
||||
appHighlightSource="spacing"
|
||||
[label]="'builder.columns' | translate"
|
||||
[hint]="'builder.sectionColumnsDesc' | translate"
|
||||
[usedBy]="[blockLabel(section.type)]"
|
||||
[usedByLabel]="'builder.usedByLabel' | translate"
|
||||
>
|
||||
<app-input type="number" [ngModel]="section.layout?.columns || 1" (ngModelChange)="updateLayout(section.id, 'columns', $event)" />
|
||||
</label>
|
||||
</app-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ import { SectionConfig } from '../../../shared/models/config';
|
||||
import { IconComponent } from '../../../shared/ui/icon/icon.component';
|
||||
import { AppIconName } from '../../../shared/ui/icon/icon-registry';
|
||||
import { ConfirmDialogComponent } from '../../../shared/ui/confirm-dialog/confirm-dialog.component';
|
||||
import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component';
|
||||
import { VisualLayoutPickerComponent, VisualLayoutOption } from '../../../shared/ui/visual-layout-picker/visual-layout-picker.component';
|
||||
import { HighlightSourceDirective } from '../../../shared/ui/highlight-source/highlight-source.directive';
|
||||
|
||||
export interface LayoutStrategyOption {
|
||||
value: 'stack' | 'grid' | 'hero' | 'carousel' | 'split';
|
||||
@@ -45,7 +48,7 @@ const BLOCK_BY_TYPE = new Map(BLOCK_CATALOG.map(entry => [entry.type, entry]));
|
||||
@Component({
|
||||
selector: 'app-project-editor-homepage-section',
|
||||
standalone: true,
|
||||
imports: [DragDropModule, FormsModule, TranslatePipe, InputComponent, SectionCardComponent, ToggleComponent, EmptyStateComponent, HomepageOverviewComponent, IconComponent, ConfirmDialogComponent],
|
||||
imports: [DragDropModule, FormsModule, TranslatePipe, InputComponent, SectionCardComponent, ToggleComponent, EmptyStateComponent, HomepageOverviewComponent, IconComponent, ConfirmDialogComponent, FormFieldComponent, VisualLayoutPickerComponent, HighlightSourceDirective],
|
||||
templateUrl: './homepage-section.component.html',
|
||||
styleUrls: ['./section.shared.scss', './homepage-section.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@@ -70,9 +73,21 @@ export class ProjectEditorHomepageSectionComponent {
|
||||
{ value: 'split', labelKey: 'builder.layoutStrategySplit', descKey: 'builder.layoutStrategySplitDesc' },
|
||||
];
|
||||
|
||||
layoutStrategyDescKey(strategy: string | undefined): string {
|
||||
return this.layoutStrategyOptions.find(option => option.value === strategy)?.descKey ?? '';
|
||||
}
|
||||
/** Card icons for the visual layout picker - purely decorative shorthand for each strategy's shape. */
|
||||
private readonly layoutStrategyIcons: Record<LayoutStrategyOption['value'], AppIconName> = {
|
||||
stack: 'list',
|
||||
grid: 'layoutGrid',
|
||||
hero: 'image',
|
||||
carousel: 'images',
|
||||
split: 'table',
|
||||
};
|
||||
|
||||
readonly layoutStrategyPickerOptions: VisualLayoutOption[] = this.layoutStrategyOptions.map(option => ({
|
||||
value: option.value,
|
||||
labelKey: option.labelKey,
|
||||
descKey: option.descKey,
|
||||
icon: this.layoutStrategyIcons[option.value],
|
||||
}));
|
||||
|
||||
blockLabel(type: string): string {
|
||||
const entry = BLOCK_BY_TYPE.get(type);
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
@if (bootstrap(); as bootstrap) {
|
||||
<app-section-card [title]="'builder.theme' | translate">
|
||||
<div class="editor-grid two">
|
||||
<app-form-field [label]="'builder.primaryColor' | translate" [hint]="'builder.primaryColorDesc' | translate" [error]="fieldError('theme.palette.primary')"><app-color-picker [ariaLabel]="'builder.primaryColor' | translate" [ngModel]="bootstrap.theme.palette.primary" (ngModelChange)="updatePalette('primary', $event)" /></app-form-field>
|
||||
<app-form-field
|
||||
appHighlightSource="color"
|
||||
[label]="'builder.primaryColor' | translate"
|
||||
[hint]="'builder.primaryColorDesc' | translate"
|
||||
[error]="fieldError('theme.palette.primary')"
|
||||
[usedBy]="[('builder.usedByButtons' | translate), ('builder.usedByLinks' | translate), ('builder.usedByBadges' | translate)]"
|
||||
[usedByLabel]="'builder.usedByLabel' | translate"
|
||||
><app-color-picker [ariaLabel]="'builder.primaryColor' | translate" [ngModel]="bootstrap.theme.palette.primary" (ngModelChange)="updatePalette('primary', $event)" /></app-form-field>
|
||||
<app-form-field [label]="'builder.secondaryColor' | translate" [hint]="'builder.secondaryColorDesc' | translate" [error]="fieldError('theme.palette.secondary')"><app-color-picker [ariaLabel]="'builder.secondaryColor' | translate" [ngModel]="bootstrap.theme.palette.secondary" (ngModelChange)="updatePalette('secondary', $event)" /></app-form-field>
|
||||
<app-form-field [label]="'builder.backgroundColor' | translate" [hint]="'builder.backgroundColorDesc' | translate" [error]="fieldError('theme.palette.backgroundPrimary')"><app-color-picker [ariaLabel]="'builder.backgroundColor' | translate" [ngModel]="bootstrap.theme.palette.backgroundPrimary" (ngModelChange)="updatePalette('backgroundPrimary', $event)" /></app-form-field>
|
||||
<app-form-field
|
||||
appHighlightSource="background"
|
||||
[label]="'builder.backgroundColor' | translate"
|
||||
[hint]="'builder.backgroundColorDesc' | translate"
|
||||
[error]="fieldError('theme.palette.backgroundPrimary')"
|
||||
[usedBy]="[('builder.usedByPageBackground' | translate)]"
|
||||
[usedByLabel]="'builder.usedByLabel' | translate"
|
||||
><app-color-picker [ariaLabel]="'builder.backgroundColor' | translate" [ngModel]="bootstrap.theme.palette.backgroundPrimary" (ngModelChange)="updatePalette('backgroundPrimary', $event)" /></app-form-field>
|
||||
<app-form-field [label]="'builder.surfaceColor' | translate" [hint]="'builder.surfaceColorDesc' | translate" [error]="fieldError('theme.palette.backgroundSecondary')"><app-color-picker [ariaLabel]="'builder.surfaceColor' | translate" [ngModel]="bootstrap.theme.palette.backgroundSecondary" (ngModelChange)="updatePalette('backgroundSecondary', $event)" /></app-form-field>
|
||||
<app-form-field [label]="'builder.textColor' | translate" [hint]="'builder.textColorDesc' | translate" [error]="fieldError('theme.palette.textPrimary')"><app-color-picker [ariaLabel]="'builder.textColor' | translate" [ngModel]="bootstrap.theme.palette.textPrimary" (ngModelChange)="updatePalette('textPrimary', $event)" /></app-form-field>
|
||||
<app-form-field [label]="'builder.successColor' | translate" [hint]="'builder.successColorDesc' | translate" [error]="fieldError('theme.palette.success')"><app-color-picker [ariaLabel]="'builder.successColor' | translate" [ngModel]="bootstrap.theme.palette.success" (ngModelChange)="updatePalette('success', $event)" /></app-form-field>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.com
|
||||
import { SectionCardComponent } from '../../../shared/ui/section-card/section-card.component';
|
||||
import { SelectComponent, SelectOption } from '../../../shared/ui/select/select.component';
|
||||
import { ColorPickerComponent } from '../../../shared/ui/color-picker/color-picker.component';
|
||||
import { HighlightSourceDirective } from '../../../shared/ui/highlight-source/highlight-source.directive';
|
||||
|
||||
export interface ThemeModeOption {
|
||||
value: 'light' | 'dark' | 'system';
|
||||
@@ -24,7 +25,7 @@ export interface SiteLayoutOption {
|
||||
@Component({
|
||||
selector: 'app-project-editor-theme-section',
|
||||
standalone: true,
|
||||
imports: [FormsModule, TranslatePipe, FormFieldComponent, SectionCardComponent, SelectComponent, ColorPickerComponent],
|
||||
imports: [FormsModule, TranslatePipe, FormFieldComponent, SectionCardComponent, SelectComponent, ColorPickerComponent, HighlightSourceDirective],
|
||||
templateUrl: './theme-section.component.html',
|
||||
styleUrls: ['./section.shared.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -868,6 +868,18 @@ export const en: Translations = {
|
||||
homepageEmptyTitle: 'No homepage configured',
|
||||
homepageEmptyDesc: 'Your marketplace does not have a homepage set up yet. Contact support to get one created.',
|
||||
homepageBlocksHint: 'These are the blocks customers see on your homepage, top to bottom. Drag the handle to reorder.',
|
||||
livePreviewTitle: 'Live preview',
|
||||
livePreviewHint: 'Hover a field to see what it changes.',
|
||||
livePreviewEmpty: 'Nothing to preview yet.',
|
||||
livePreviewButtonLabel: 'Shop now',
|
||||
usedByLabel: 'Used by:',
|
||||
usedByHeader: 'Header',
|
||||
usedByFooter: 'Footer',
|
||||
usedBySeo: 'Search results',
|
||||
usedByButtons: 'Buttons',
|
||||
usedByLinks: 'Links',
|
||||
usedByBadges: 'Badges',
|
||||
usedByPageBackground: 'Page background',
|
||||
addBlockHint: 'Add a block to your homepage:',
|
||||
dragToReorder: 'Drag to reorder',
|
||||
blockRemoveConfirm: 'Remove this block from the homepage?',
|
||||
|
||||
@@ -868,6 +868,18 @@ export const hy: Translations = {
|
||||
homepageEmptyTitle: 'Գլխավոր էջը կարգավորված չէ',
|
||||
homepageEmptyDesc: 'Ձեր խանութն դեռ չունի կարգավորված գլխավոր էջ։ Ստեղծելու համար դիմեք աջակցության թիմին։',
|
||||
homepageBlocksHint: 'Սրանք են բլոկները, որոնք գնորդները տեսնում են գլխավոր էջում վերևից ներքև։ Քաշեք բռնակից՝ կարգը փոխելու համար։',
|
||||
livePreviewTitle: 'Կենդանի նախադիտում',
|
||||
livePreviewHint: 'Անցկացրեք դաշտի վրա՝ տեսնելու համար, թե ինչ է այն փոխում։',
|
||||
livePreviewEmpty: 'Դեռ ցուցադրելու բան չկա։',
|
||||
livePreviewButtonLabel: 'Գնումներ',
|
||||
usedByLabel: 'Օգտագործվում է՝',
|
||||
usedByHeader: 'Վերնագիր',
|
||||
usedByFooter: 'Ստորագիր',
|
||||
usedBySeo: 'Որոնման արդյունքներ',
|
||||
usedByButtons: 'Կոճակներ',
|
||||
usedByLinks: 'Հղումներ',
|
||||
usedByBadges: 'Կրծքանշաններ',
|
||||
usedByPageBackground: 'Էջի ֆոն',
|
||||
addBlockHint: 'Ավելացնել բլոկ գլխավոր էջում.',
|
||||
dragToReorder: 'Քաշեք՝ կարգը փոխելու համար',
|
||||
blockRemoveConfirm: 'Հեռացնե՞լ այս բլոկը գլխավոր էջից։',
|
||||
|
||||
@@ -868,6 +868,18 @@ export const ru: Translations = {
|
||||
homepageEmptyTitle: 'Главная страница не настроена',
|
||||
homepageEmptyDesc: 'В вашем магазине пока не настроена главная страница. Обратитесь в поддержку, чтобы её создать.',
|
||||
homepageBlocksHint: 'Это блоки, которые покупатели видят на главной странице сверху вниз. Перетаскивайте за ручку, чтобы изменить порядок.',
|
||||
livePreviewTitle: 'Живой предпросмотр',
|
||||
livePreviewHint: 'Наведите курсор на поле, чтобы увидеть, что оно меняет.',
|
||||
livePreviewEmpty: 'Пока нечего показать.',
|
||||
livePreviewButtonLabel: 'В магазин',
|
||||
usedByLabel: 'Используется в:',
|
||||
usedByHeader: 'Шапка сайта',
|
||||
usedByFooter: 'Подвал сайта',
|
||||
usedBySeo: 'Результаты поиска',
|
||||
usedByButtons: 'Кнопки',
|
||||
usedByLinks: 'Ссылки',
|
||||
usedByBadges: 'Значки',
|
||||
usedByPageBackground: 'Фон страницы',
|
||||
addBlockHint: 'Добавить блок на главную страницу:',
|
||||
dragToReorder: 'Перетащите, чтобы изменить порядок',
|
||||
blockRemoveConfirm: 'Удалить этот блок с главной страницы?',
|
||||
|
||||
@@ -869,6 +869,18 @@ export interface Translations {
|
||||
homepageBlocksHint: string;
|
||||
addBlockHint: string;
|
||||
dragToReorder: string;
|
||||
livePreviewTitle: string;
|
||||
livePreviewHint: string;
|
||||
livePreviewEmpty: string;
|
||||
livePreviewButtonLabel: string;
|
||||
usedByLabel: string;
|
||||
usedByHeader: string;
|
||||
usedByFooter: string;
|
||||
usedBySeo: string;
|
||||
usedByButtons: string;
|
||||
usedByLinks: string;
|
||||
usedByBadges: string;
|
||||
usedByPageBackground: string;
|
||||
blockRemoveConfirm: string;
|
||||
blockHero: string;
|
||||
blockHeroDesc: string;
|
||||
|
||||
17
src/app/shared/services/preview-highlight.service.ts
Normal file
17
src/app/shared/services/preview-highlight.service.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Cross-component hover/focus bridge between an editor field and the schematic
|
||||
* live preview. A field marks itself with `appHighlightSource="logo"` and the
|
||||
* preview marks the matching element with `appHighlightTarget="logo"`; this
|
||||
* service is the shared signal both sides read/write so they don't need a
|
||||
* direct reference to each other.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PreviewHighlightService {
|
||||
readonly activeId = signal<string | null>(null);
|
||||
|
||||
set(id: string | null): void {
|
||||
this.activeId.set(id);
|
||||
}
|
||||
}
|
||||
@@ -17,4 +17,15 @@
|
||||
} @else if (hint()) {
|
||||
<p class="app-form-field__hint" [id]="hintId">{{ hint() }}</p>
|
||||
}
|
||||
|
||||
@if (usedBy(); as targets) {
|
||||
@if (targets.length) {
|
||||
<p class="app-form-field__used-by" [id]="usedById">
|
||||
@if (usedByLabel()) {
|
||||
<span class="app-form-field__used-by-label">{{ usedByLabel() }}</span>
|
||||
}
|
||||
{{ targets.join(', ') }}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -30,3 +30,14 @@
|
||||
font-size: var(--font-size-sm, 0.8125rem);
|
||||
color: var(--error-color, #c0392b);
|
||||
}
|
||||
|
||||
.app-form-field__used-by {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.app-form-field__used-by-label {
|
||||
font-weight: var(--font-weight-semibold, 600);
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -24,10 +24,20 @@ export class FormFieldComponent implements FormFieldContext {
|
||||
readonly hint = input<string | null>(null);
|
||||
readonly error = input<string | null>(null);
|
||||
readonly required = input(false);
|
||||
/** Optional "Where is this used?" list, e.g. ['Buttons', 'Links', 'Badges']. Already-translated strings. */
|
||||
readonly usedBy = input<readonly string[] | null>(null);
|
||||
readonly usedByLabel = input<string | null>(null);
|
||||
|
||||
readonly fieldId = `app-form-field-${++nextFormFieldId}`;
|
||||
protected readonly hintId = `${this.fieldId}-hint`;
|
||||
protected readonly errorId = `${this.fieldId}-error`;
|
||||
protected readonly usedById = `${this.fieldId}-used-by`;
|
||||
|
||||
readonly describedBy = computed(() => this.error() ? this.errorId : (this.hint() ? this.hintId : null));
|
||||
readonly describedBy = computed(() => {
|
||||
const ids = [
|
||||
this.error() ? this.errorId : (this.hint() ? this.hintId : null),
|
||||
this.usedBy()?.length ? this.usedById : null,
|
||||
].filter((id): id is string => !!id);
|
||||
return ids.length ? ids.join(' ') : null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Directive, HostListener, inject, input } from '@angular/core';
|
||||
import { PreviewHighlightService } from '../../services/preview-highlight.service';
|
||||
|
||||
/**
|
||||
* Attach to any field/control wrapper: `<label appHighlightSource="logo">`.
|
||||
* On hover or keyboard focus it tells PreviewHighlightService which schematic
|
||||
* preview element to highlight; clears on leave/blur.
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[appHighlightSource]',
|
||||
standalone: true,
|
||||
})
|
||||
export class HighlightSourceDirective {
|
||||
private readonly highlight = inject(PreviewHighlightService);
|
||||
|
||||
readonly appHighlightSource = input.required<string>();
|
||||
|
||||
@HostListener('mouseenter')
|
||||
@HostListener('focusin')
|
||||
onActivate(): void {
|
||||
this.highlight.set(this.appHighlightSource());
|
||||
}
|
||||
|
||||
@HostListener('mouseleave')
|
||||
@HostListener('focusout')
|
||||
onDeactivate(): void {
|
||||
if (this.highlight.activeId() === this.appHighlightSource()) {
|
||||
this.highlight.set(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<div
|
||||
class="app-layout-picker"
|
||||
role="radiogroup"
|
||||
[attr.aria-label]="ariaLabel()"
|
||||
[attr.aria-describedby]="formField?.describedBy() ?? null"
|
||||
>
|
||||
@for (option of options(); track option.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="app-layout-picker__card"
|
||||
[class.app-layout-picker__card--selected]="value === option.value"
|
||||
role="radio"
|
||||
[attr.aria-checked]="value === option.value"
|
||||
[disabled]="isDisabled"
|
||||
(click)="select(option.value)"
|
||||
>
|
||||
<span class="app-layout-picker__preview app-layout-picker__preview--{{ option.value }}" aria-hidden="true">
|
||||
<app-icon [name]="option.icon" [size]="20" />
|
||||
</span>
|
||||
<span class="app-layout-picker__label">{{ option.labelKey | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (activeDescription(); as descKey) {
|
||||
<p class="app-layout-picker__desc">{{ descKey | translate }}</p>
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.app-layout-picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||
gap: var(--space-sm, 0.5rem);
|
||||
}
|
||||
|
||||
.app-layout-picker__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-xs, 0.25rem);
|
||||
padding: var(--space-sm, 0.5rem);
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-color, #e4e4e7);
|
||||
border-radius: var(--radius-lg, 10px);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary, #1f322d);
|
||||
transition: border-color var(--transition-fast, 120ms ease),
|
||||
box-shadow var(--transition-fast, 120ms ease);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--primary-color, #2f6e5d);
|
||||
box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.06));
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color, #2f6e5d);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.app-layout-picker__card--selected {
|
||||
border-color: var(--primary-color, #2f6e5d);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color, #2f6e5d) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--primary-color, #2f6e5d) 6%, var(--bg-primary, #fff));
|
||||
}
|
||||
|
||||
.app-layout-picker__preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
background: var(--bg-secondary, #f4f4f5);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.app-layout-picker__card--selected .app-layout-picker__preview {
|
||||
color: var(--primary-color, #2f6e5d);
|
||||
}
|
||||
|
||||
.app-layout-picker__label {
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
text-align: center;
|
||||
line-height: var(--line-height-normal, 1.5);
|
||||
}
|
||||
|
||||
.app-layout-picker__desc {
|
||||
margin: var(--space-xs, 0.25rem) 0 0;
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-layout-picker__card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ChangeDetectionStrategy, Component, forwardRef, inject, input } from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { FormFieldContext } from '../form-field/form-field.component';
|
||||
import { IconComponent } from '../icon/icon.component';
|
||||
import { AppIconName } from '../icon/icon-registry';
|
||||
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||||
|
||||
export interface VisualLayoutOption {
|
||||
value: string;
|
||||
labelKey: string;
|
||||
descKey: string;
|
||||
icon: AppIconName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card-based replacement for a plain layout `<select>`. Same ControlValueAccessor
|
||||
* shape as app-select so it drops into existing `[ngModel]`/`[(ngModel)]` bindings,
|
||||
* but renders each option as a selectable preview card (radiogroup) instead of a
|
||||
* dropdown, so non-technical admins can recognize the layout instead of reading its name.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-visual-layout-picker',
|
||||
standalone: true,
|
||||
imports: [IconComponent, TranslatePipe],
|
||||
templateUrl: './visual-layout-picker.component.html',
|
||||
styleUrl: './visual-layout-picker.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => VisualLayoutPickerComponent),
|
||||
multi: true
|
||||
}
|
||||
]
|
||||
})
|
||||
export class VisualLayoutPickerComponent implements ControlValueAccessor {
|
||||
protected readonly formField = inject(FormFieldContext, { optional: true });
|
||||
|
||||
readonly options = input.required<VisualLayoutOption[]>();
|
||||
readonly ariaLabel = input<string | null>(null);
|
||||
readonly disabled = input(false);
|
||||
|
||||
protected value = '';
|
||||
protected isDisabled = false;
|
||||
|
||||
private onChange: (value: string) => void = () => {};
|
||||
private onTouched: () => void = () => {};
|
||||
|
||||
writeValue(value: string): void {
|
||||
this.value = value ?? '';
|
||||
}
|
||||
|
||||
registerOnChange(fn: (value: string) => void): void {
|
||||
this.onChange = fn;
|
||||
}
|
||||
|
||||
registerOnTouched(fn: () => void): void {
|
||||
this.onTouched = fn;
|
||||
}
|
||||
|
||||
setDisabledState(isDisabled: boolean): void {
|
||||
this.isDisabled = isDisabled;
|
||||
}
|
||||
|
||||
protected select(optionValue: string): void {
|
||||
if (this.isDisabled) {
|
||||
return;
|
||||
}
|
||||
this.value = optionValue;
|
||||
this.onChange(optionValue);
|
||||
this.onTouched();
|
||||
}
|
||||
|
||||
protected activeDescription(): string | null {
|
||||
return this.options().find(option => option.value === this.value)?.descKey ?? null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user