Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<div class="richTextField-toolbar-chips" (keydown.arrowleft)="focusTag($event, -1)" (keydown.arrowRight)="focusTag($event, 1)">
<span aria-hidden="true">{{ intl().insertTag }}</span>
@for (tag of tags(); track tag) {
@for (tag of primaryTags(); track tag) {
<lu-chip
[attr.tabindex]="isDisabled() ? null : $first ? 0 : -1"
[disabled]="isDisabled()"
Expand All @@ -16,4 +16,38 @@
{{ tag.description }}
</lu-chip>
}
@if (secondaryTags().length) {
<button
luButton="ghost"
type="button"
size="XS"
[luPopover2]="tagsPopover"
[customPositions]="popoverPositions"
[attr.aria-expanded]="tagsTrigger.opened()"
Comment thread
MarcFairbrother marked this conversation as resolved.
Outdated
(luPopoverClosed)="tagSearch.set('')"
#tagButton
#tagsTrigger="luPopover2"
>
<lu-icon icon="mathsPlus" />
{{ intl().selectTag }}
</button>

<ng-template #tagsPopover>
<div class="popover-contentOptional">
<lu-simple-select
[formControl]="selectedTagControl"
[options]="filteredSecondaryTags()"
(clueChange)="tagSearch.set($event)"
Comment thread
MarcFairbrother marked this conversation as resolved.
Outdated
#tagsSelect
>
@if (secondaryHasGroup()) {
<ng-container *luOptionGroup="let group; by: groupByTagGroup; select: tagsSelect">
{{ group.key }}
</ng-container>
}
<ng-container *luOption="let tag; select: tagsSelect">{{ tag.description }}</ng-container>
</lu-simple-select>
</div>
</ng-template>
}
</div>
77 changes: 74 additions & 3 deletions packages/ng/forms/rich-text-input/plugins/tag/tag.component.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { ChangeDetectionStrategy, Component, effect, ElementRef, forwardRef, inject, input, OnDestroy, signal, viewChildren, ViewContainerRef } from '@angular/core';
import { ConnectionPositionPair } from '@angular/cdk/overlay';
import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, forwardRef, inject, input, OnDestroy, signal, untracked, viewChild, viewChildren, ViewContainerRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { ButtonComponent } from '@lucca-front/ng/button';
import { ChipComponent } from '@lucca-front/ng/chip';
import { intlInputOptions } from '@lucca-front/ng/core';
import { intlInputOptions, isNotNil } from '@lucca-front/ng/core';
import { LuOptionDirective, LuOptionGroupDirective } from '@lucca-front/ng/core-select';
import { PopoverDirective } from '@lucca-front/ng/popover2';
import { LuSimpleSelectInputComponent } from '@lucca-front/ng/simple-select';
import { IconComponent } from '@lucca/prisme/icon';
import { $getNodeByKey, $getRoot, $getSelection, type Klass, type LexicalEditor, type LexicalNode, type NodeKey, SKIP_DOM_SELECTION_TAG } from 'lexical';
import { INITIAL_UPDATE_TAG, RICH_TEXT_PLUGIN_COMPONENT, RichTextPluginComponent } from '../../rich-text-input.component';
import { LU_RICH_TEXT_INPUT_TRANSLATIONS } from '../../rich-text-input.translate';
Expand All @@ -14,7 +22,7 @@ const areSetsEqual = (a: Set<string>, b: Set<string>): boolean => a.size === b.s
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './tag.component.html',
styleUrl: './tag.component.scss',
imports: [ChipComponent],
imports: [ButtonComponent, ChipComponent, IconComponent, LuSimpleSelectInputComponent, PopoverDirective, LuOptionGroupDirective, LuOptionDirective, ReactiveFormsModule],
providers: [
{
provide: RICH_TEXT_PLUGIN_COMPONENT,
Expand All @@ -31,6 +39,28 @@ export class RichTextPluginTagComponent implements RichTextPluginComponent, OnDe
readonly focusIndex = signal<number>(0);

readonly focusableElements = viewChildren('tagButton', { read: ElementRef });
readonly primaryTags = computed(() => this.tags().filter((t) => !t.secondary));
readonly secondaryTags = computed(() => this.tags().filter((t) => t.secondary));
readonly filteredSecondaryTags = computed(() => {
const search = this.#normalize(this.tagSearch());
return this.secondaryTags().filter((t) => {
const description = t.description ? this.#normalize(t.description) : '';
return !search || description.includes(search);
});
});
readonly secondaryHasGroup = computed(() => this.secondaryTags().some((s) => isNotNil(s.group)));
readonly tagSearch = signal<string>('');
readonly selectedTagControl = new FormControl<Tag | null>(null);

// The select panel only exists once the popover is opened, so these queries resolve lazily.
readonly tagsSelectRef = viewChild<LuSimpleSelectInputComponent<Tag>>(LuSimpleSelectInputComponent);
readonly popoverRef = viewChild(PopoverDirective);

// Anchor the dropdown to the trigger button, opening below it (flipping above if there isn't room).
readonly popoverPositions: ConnectionPositionPair[] = [
new ConnectionPositionPair({ originX: 'start', originY: 'bottom' }, { overlayX: 'start', overlayY: 'top' }, -4, 0),
new ConnectionPositionPair({ originX: 'start', originY: 'top' }, { overlayX: 'start', overlayY: 'bottom' }, -4, 0),
];

editor: LexicalEditor | null = null;

Expand Down Expand Up @@ -65,8 +95,38 @@ export class RichTextPluginTagComponent implements RichTextPluginComponent, OnDe
{ tag: [SKIP_DOM_SELECTION_TAG, INITIAL_UPDATE_TAG] },
);
});

this.selectedTagControl.valueChanges.pipe(takeUntilDestroyed()).subscribe((tag) => {
if (tag) {
this.selectedTagControl.reset();
this.insertTag(tag);
}
});

// Render the select's panel inline inside our own popover (instead of through a filter-pill).
// The select component only appears once the popover opens, so we wire it up reactively.
effect(() => {
const ref = this.tagsSelectRef();
if (ref) {
untracked(() => {
ref.enableFilterPillMode();
ref.registerFilterPillClosePopover(this.closePopover);
Comment thread
MarcFairbrother marked this conversation as resolved.
Outdated
ref.registerFilterPillUpdatePosition?.(this.updatePosition);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the select panel is not exported by LF & cannot be used separately from select component? My concern with is that we register the callback but never unregister. Depending on internal implementation, that could lead to memory leaks & event handler duplications.

ref.onFilterPillOpened?.();
});
}
});
}

closePopover = () => {
this.popoverRef()?.close();
this.tagsSelectRef()?.onFilterPillClosed?.();
};

updatePosition = () => {
this.popoverRef()?.updatePosition();
};

setEditorInstance(editor: LexicalEditor): void {
this.editor = editor;

Expand Down Expand Up @@ -127,4 +187,15 @@ export class RichTextPluginTagComponent implements RichTextPluginComponent, OnDe
ngOnDestroy() {
this.#registeredCommands();
}

groupByTagGroup(tag: Tag) {
return tag.group;
}

#normalize(str: string): string {
return str
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toLowerCase();
}
Comment on lines +168 to +173

@obraud obraud Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have a common utility file for this kind of code? @Supamiu

}
2 changes: 2 additions & 0 deletions packages/ng/forms/rich-text-input/plugins/tag/tag.model.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export type Tag = {
key: string;
description?: string;
secondary?: boolean;
group?: string;
};
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface ILuRichTextInputLabel {
stylesStrikethrough: string;

insertTag: string;
selectTag: string;
}

export const luRichTextInputTranslations: LuTranslation<ILuRichTextInputLabel> = Translations;
8 changes: 8 additions & 0 deletions packages/ng/forms/rich-text-input/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
stylesStrikethrough: 'Doorgehaald',
linksPlaceholder: 'https://www.nomDuSite.com',
insertTag: 'Invoegen:',
selectTag: 'Een tag toevoegen',
},
fr: {
clearFormat: 'Effacer la mise en forme',
Expand All @@ -46,6 +47,7 @@
stylesStrikethrough: 'Barré',
linksPlaceholder: 'https://www.nomDuSite.com',
insertTag: 'Insérer :',
selectTag: 'Ajouter une balise',
},
en: {
clearFormat: 'Delete formatting',
Expand All @@ -70,6 +72,7 @@
stylesStrikethrough: 'Strikethrough',
linksPlaceholder: 'https://www.SiteName.com',
insertTag: 'Insert:',
selectTag: 'Add a tag',
},
de: {
clearFormat: 'Formatierung löschen',
Expand All @@ -94,6 +97,7 @@
stylesStrikethrough: 'Durchgestrichen',
linksPlaceholder: 'https://www.NameDerSeite.com',
insertTag: 'Einfügen:',
selectTag: 'Ein Tag hinzufügen',
},
it: {
clearFormat: 'Cancellare la formattazione',
Expand All @@ -118,6 +122,7 @@
stylesStrikethrough: 'Barrato',
linksPlaceholder: 'https://www.nomDuSite.com',
insertTag: 'Inserire:',
selectTag: 'Aggiungi un tag',
},
nl: {
clearFormat: 'Opmaak wissen',
Expand All @@ -142,6 +147,7 @@
stylesStrikethrough: 'Doorgehaald',
linksPlaceholder: 'https://www.nomDuSite.com',
insertTag: 'Invoegen:',
selectTag: 'Een tag toevoegen',
},
es: {
clearFormat: 'Borrar formato',
Expand All @@ -166,6 +172,7 @@
stylesStrikethrough: 'Tachado',
linksPlaceholder: 'https://www.nomDuSite.com',
insertTag: 'Introducir:',
selectTag: 'Añadir una etiqueta',
},
pt: {
clearFormat: 'Eliminar a formatação',
Expand All @@ -190,5 +197,6 @@
stylesStrikethrough: 'Barré',
linksPlaceholder: 'https://www.nomeDoSitio.com/',
insertTag: 'Inserir:',
selectTag: 'Adicionar uma etiqueta',
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,19 @@ export const WithTagPluginWithNoInitialValue: StoryObj<RichTextInputComponent &
description: 'Tag 2',
},
{
key: 'tag3',
description: 'Tag 3',
key: 'tag4',
description: 'Tag 4',
secondary: true,
},
{
key: 'tag5',
description: 'Prénom',
secondary: true,
},
{
key: 'tag6',
description: 'Téléphone',
secondary: true,
},
]" />
</lu-rich-text-input>
Expand Down Expand Up @@ -210,6 +221,24 @@ export const WithTagPlugin: StoryObj<RichTextInputComponent & { value: string; d
key: 'tag3',
description: 'Tag 3',
},
{
key: 'tag4',
description: 'Tag 4',
secondary: true,
group: 'Groupe 2'
},
{
key: 'tag6',
description: 'Téléphone',
secondary: true,
group: 'Groupe 2'
},
{
key: 'tag5',
description: 'Prénom',
secondary: true,
group: 'Groupe 1'
},
]" />
</lu-rich-text-input>
</lu-form-field>
Expand Down Expand Up @@ -255,6 +284,21 @@ export const WithTagPluginMarkdown: StoryObj<RichTextInputComponent & { value: s
key: 'tag3',
description: 'Tag 3',
},
{
key: 'tag4',
description: 'Tag 4',
secondary: true,
},
{
key: 'tag5',
description: 'Prénom',
secondary: true,
},
{
key: 'tag6',
description: 'Téléphone',
secondary: true,
},
]" />
</lu-rich-text-input>
</lu-form-field>
Expand All @@ -265,7 +309,7 @@ export const WithTagPluginMarkdown: StoryObj<RichTextInputComponent & { value: s
};
},
args: {
value: 'Lorem **ipsum** dolor {{tag1}} *italic* {{unregisteredTag}} and regular {{tag2}} trailing text\nLine 2\n\nParagraph 2\n\n\n\nParagraph 3',
value: 'Lorem **ipsum** dolor {{tag1}} *italic* {{unregisteredTag}} and {{tag4}} regular {{tag2}} trailing text\nLine 2\n\nParagraph 2\n\n\n\nParagraph 3',
placeholder: 'Placeholder…',
disabled: false,
required: false,
Expand Down Expand Up @@ -348,6 +392,21 @@ export const WithTagPluginMarkdownContentChange: StoryObj<RichTextInputComponent
key: 'tag3',
description: 'Tag 3',
},
{
key: 'tag4',
description: 'Tag 4',
secondary: true,
},
{
key: 'tag5',
description: 'Prénom',
secondary: true,
},
{
key: 'tag6',
description: 'Téléphone',
secondary: true,
},
]"/>
</lu-rich-text-input>
</lu-form-field>
Expand Down
Loading