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:
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