diff --git a/packages/components-dev/inline-edit/module.ts b/packages/components-dev/inline-edit/module.ts index 9ebe5053c9..c5445e3ec8 100644 --- a/packages/components-dev/inline-edit/module.ts +++ b/packages/components-dev/inline-edit/module.ts @@ -18,6 +18,9 @@ import { DevThemeToggle } from '../theme-toggle'; + + + diff --git a/packages/components/core/form-field/form-field-ref.ts b/packages/components/core/form-field/form-field-ref.ts index a2161f3c8e..321e3af245 100644 --- a/packages/components/core/form-field/form-field-ref.ts +++ b/packages/components/core/form-field/form-field-ref.ts @@ -1,4 +1,4 @@ -import { InjectionToken, ModelSignal, Signal } from '@angular/core'; +import { ElementRef, InjectionToken, ModelSignal, Signal } from '@angular/core'; import { NgControl } from '@angular/forms'; import { Observable } from 'rxjs'; @@ -59,3 +59,24 @@ export interface KbqFormFieldRef { * @TODO move into form-field.ts, add correct type for `InjectionToken` (#DS-2915) */ export const KBQ_FORM_FIELD_REF = new InjectionToken('KbqFormFieldRef'); + +/** + * Contract for an ancestor that wants to override where overlays opened by a nested + * `KbqFormField`'s control (e.g. a select's dropdown, a datepicker's calendar) are + * anchored, instead of the form-field's own container. + */ +export interface KbqConnectedOverlayOriginProvider { + /** + * Element the overlay should be positioned and sized against, or `undefined` to fall + * back to the form-field's own container. + */ + getConnectedOverlayOrigin(): ElementRef | undefined; +} + +/** + * Injection token for `KbqConnectedOverlayOriginProvider`. Provide it on an ancestor + * component to redirect where a nested `KbqFormField`'s control positions its overlay. + */ +export const KBQ_CONNECTED_OVERLAY_ORIGIN = new InjectionToken( + 'KbqConnectedOverlayOrigin' +); diff --git a/packages/components/form-field/form-field.ts b/packages/components/form-field/form-field.ts index 56f6eb12e5..b0ae3b10c9 100644 --- a/packages/components/form-field/form-field.ts +++ b/packages/components/form-field/form-field.ts @@ -27,7 +27,7 @@ import { } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NgControl } from '@angular/forms'; -import { KBQ_FORM_FIELD_REF, KbqColorDirective } from '@koobiq/components/core'; +import { KBQ_CONNECTED_OVERLAY_ORIGIN, KBQ_FORM_FIELD_REF, KbqColorDirective } from '@koobiq/components/core'; import { EMPTY, merge } from 'rxjs'; import { delay, startWith } from 'rxjs/operators'; import { KbqCleaner } from './cleaner'; @@ -145,6 +145,7 @@ export class KbqFormField private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly focusMonitor = inject(FocusMonitor); private readonly defaultOptions = inject(KBQ_FORM_FIELD_DEFAULT_OPTIONS, { optional: true }); + private readonly customOverlayOrigin = inject(KBQ_CONNECTED_OVERLAY_ORIGIN, { optional: true }); /** * @docs-private */ @@ -450,7 +451,9 @@ export class KbqFormField * Gets an ElementRef for the element that a overlay attached to the form-field should be positioned relative to. */ getConnectedOverlayOrigin(): ElementRef { - return this.connectionContainerRef() || this.elementRef; + return ( + this.customOverlayOrigin?.getConnectedOverlayOrigin() ?? this.connectionContainerRef() ?? this.elementRef + ); } /** diff --git a/packages/components/inline-edit/__screenshots__/06-dark.png b/packages/components/inline-edit/__screenshots__/06-dark.png new file mode 100644 index 0000000000..70c76c5262 Binary files /dev/null and b/packages/components/inline-edit/__screenshots__/06-dark.png differ diff --git a/packages/components/inline-edit/__screenshots__/06-light.png b/packages/components/inline-edit/__screenshots__/06-light.png new file mode 100644 index 0000000000..04468be13d Binary files /dev/null and b/packages/components/inline-edit/__screenshots__/06-light.png differ diff --git a/packages/components/inline-edit/e2e.playwright-spec.ts b/packages/components/inline-edit/e2e.playwright-spec.ts index fcacf3fbc1..ef9c7906a7 100644 --- a/packages/components/inline-edit/e2e.playwright-spec.ts +++ b/packages/components/inline-edit/e2e.playwright-spec.ts @@ -85,6 +85,23 @@ test.describe('KbqInlineEdit', () => { }); }); + test.describe('E2eInlineEditSelectMultiline', () => { + const getContainer = (page: Page) => page.getByTestId('e2eInlineEditSelectMultilineContainer'); + const getInlineEdit = (locator: Locator) => locator.getByTestId('e2eInlineEditSelectMultiline'); + + test('multiline select-style editor', async ({ page }) => { + await page.goto('/E2eInlineEditSelectMultiline'); + + const screenshotTarget = getContainer(page); + + await getInlineEdit(screenshotTarget).click(); + + await expect(screenshotTarget).toHaveScreenshot('06-light.png'); + await e2eEnableDarkTheme(page); + await expect(screenshotTarget).toHaveScreenshot('06-dark.png'); + }); + }); + test.describe('E2eInlineEditMenuButton', () => { const getComponent = (page: Page) => page.getByTestId('e2eInlineEditMenuButton'); const getContainer = (page: Page) => page.getByTestId('e2eInlineEditMenuButtonContainer'); diff --git a/packages/components/inline-edit/e2e.ts b/packages/components/inline-edit/e2e.ts index 8f1f5f4e4d..6b88a78058 100644 --- a/packages/components/inline-edit/e2e.ts +++ b/packages/components/inline-edit/e2e.ts @@ -2,10 +2,12 @@ import { NgTemplateOutlet } from '@angular/common'; import { ChangeDetectionStrategy, Component, viewChildren } from '@angular/core'; import { FormControl, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { KbqButtonModule } from '@koobiq/components/button'; -import { kbqInjectNativeElement } from '@koobiq/components/core'; +import { kbqInjectNativeElement, KbqOptionModule } from '@koobiq/components/core'; import { KbqDropdownModule } from '@koobiq/components/dropdown'; +import { KbqFormFieldModule, KbqLabel } from '@koobiq/components/form-field'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqInputModule } from '@koobiq/components/input'; +import { KbqSelectModule } from '@koobiq/components/select'; import { KbqTextareaModule } from '@koobiq/components/textarea'; import { KbqInlineEdit } from './inline-edit'; import { KbqInlineEditModule } from './module'; @@ -251,3 +253,74 @@ export class E2eInlineEditMenuButton {} export class E2eInlineEditActionButtons { readonly control = new FormControl('Initial value', Validators.required); } + +const E2E_COMMENTS: string[] = [ + 'Issue resolved after restarting the affected service. No further action required.', + 'Root cause identified as a misconfigured environment variable, fixed and redeployed.', + 'Duplicate of an existing issue, closing without further changes.', + 'Waiting on additional information from the reporter before further diagnosis.' +]; + +@Component({ + selector: 'e2e-inline-edit-select-multiline', + imports: [ + ReactiveFormsModule, + KbqInlineEditModule, + KbqFormFieldModule, + KbqLabel, + KbqOptionModule, + KbqSelectModule + ], + template: ` +
+ + Resolution comment + +
+ @if (control.value) { + {{ control.value }} + } @else { + {{ notSetLabel }} + } +
+ + + + {{ notSetLabel }} + @for (comment of comments; track comment) { + {{ comment }} + } + + +
+
+ `, + styles: ` + :host { + display: block; + width: 350px; + height: 400px; + padding: 8px; + } + + ::ng-deep .e2e-inline-edit-select-multiline__options .kbq-option-text { + white-space: normal; + overflow-wrap: break-word; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'layout-margin-top-l', + 'data-testid': 'e2eInlineEditSelectMultilineContainer' + } +}) +export class E2eInlineEditSelectMultiline { + protected readonly notSetLabel = 'Not specified'; + protected readonly comments = E2E_COMMENTS; + protected readonly control = new FormControl(E2E_COMMENTS[0]); +} diff --git a/packages/components/inline-edit/inline-edit.en.md b/packages/components/inline-edit/inline-edit.en.md index 0d9f0179b4..46a031a58b 100644 --- a/packages/components/inline-edit/inline-edit.en.md +++ b/packages/components/inline-edit/inline-edit.en.md @@ -70,6 +70,30 @@ Inline-editable elements should remain aligned with their appearance in view mod +#### Select-style editor + +On selection, open only the dropdown menu — don't show a bordered input field like Select's. The field doesn't need a border of its own, the dropdown alone is enough. + + + +After picking an option, the field returns to view mode. + +#### Multiline field + +Sometimes it's worth making the dropdown options multiline. It's recommended to match the dropdown's width to the field's width. + + + +#### Reset + +Place the clear element inside the dropdown menu — since the select field itself isn't visible, there's no other suitable place for a reset button. + +Also recommended: add an option whose label makes it clear that no value is set. This echoes the wording used for the empty field in view mode. + + + +After picking an option, the field returns to view mode. + ### Recommendations Inline edits should be saved immediately after a change—without requiring a global **Save** button. diff --git a/packages/components/inline-edit/inline-edit.html b/packages/components/inline-edit/inline-edit.html index 3849b982cc..4d9e8f18e6 100644 --- a/packages/components/inline-edit/inline-edit.html +++ b/packages/components/inline-edit/inline-edit.html @@ -44,7 +44,12 @@ (overlayKeydown)="onOverlayKeydown($event)" > -
+
+#### Селект без рамки + +При выборе открываем только выпадающее меню, но не показываем поле ввода, как у Select. Рамка поля ввода не требуется — достаточно выпадающего меню. + + + +После выбора опции поле возвращается в режим просмотра. + +#### Многострочное поле + +Иногда стоит делать опции в выпадающем списке многострочными. Рекомендуется выравнивать ширину выпадающего меню по ширине поля. + + + +#### Сброс + +Разместите элемент для очистки в выпадающем меню — ведь самого поля селекта не видно, поэтому другого подходящего места для кнопки сброса нет. + +Рекомендуем также добавить опцию, название которой явно даёт понять, что значение не установлено. Это будет логично перекликаться с формулировкой пустого поля в режиме просмотра. + + + +После выбора опции поле возвращается в режим просмотра. + ### Рекомендации Поле с инлайн-редактированием должны сохраняться сразу после изменения без общей для всего экрана кнопки «Сохранить». diff --git a/packages/components/inline-edit/inline-edit.scss b/packages/components/inline-edit/inline-edit.scss index bd423fbdaa..afc299b8f8 100644 --- a/packages/components/inline-edit/inline-edit.scss +++ b/packages/components/inline-edit/inline-edit.scss @@ -125,6 +125,12 @@ } } + // Select-style editor: edit mode shows only the dropdown, not a bordered field, so the view + // text keeps showing through and just gets the "active" look instead. + &.kbq-inline-edit_select.kbq-inline-edit_edit { + --kbq-inline-edit-background: var(--kbq-states-background-transparent-active); + } + &:hover, &.cdk-keyboard-focused, &.kbq-inline-edit_anchor-focused { @@ -187,6 +193,10 @@ padding: var(--kbq-size-xxs) var(--kbq-size-s) var(--kbq-size-s); } + &.kbq-inline-edit__panel_select .kbq-inline-edit__control-container { + display: none; + } + .kbq-inline-edit__control-container, .kbq-inline-edit__action-button { box-shadow: var(--kbq-shadow-popup); diff --git a/packages/components/inline-edit/inline-edit.spec.ts b/packages/components/inline-edit/inline-edit.spec.ts index d1e7f88109..2b8e1cb743 100644 --- a/packages/components/inline-edit/inline-edit.spec.ts +++ b/packages/components/inline-edit/inline-edit.spec.ts @@ -16,7 +16,7 @@ import { TAB } from '@koobiq/components/core'; import { KbqDropdownModule } from '@koobiq/components/dropdown'; -import { KbqFormFieldModule } from '@koobiq/components/form-field'; +import { KbqFormField, KbqFormFieldModule } from '@koobiq/components/form-field'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqInputModule } from '@koobiq/components/input'; import { KbqSelectModule } from '@koobiq/components/select'; @@ -349,6 +349,42 @@ describe('KbqInlineEdit', () => { expect(spyFn).not.toHaveBeenCalled(); }); + it('should save and return to view mode when commit() is called directly', () => { + const fixture = setup(TestComponent); + const { componentInstance, debugElement } = fixture; + const inlineEditDebugElement: DebugElement = getInlineEditDebugElement(debugElement); + const spyFn = jest.spyOn(componentInstance, 'update'); + + inlineEditDebugElement.nativeElement.click(); + fixture.detectChanges(); + + (inlineEditDebugElement.componentInstance as KbqInlineEdit).commit(); + fixture.detectChanges(); + + expect(spyFn).toHaveBeenCalled(); + expect(inlineEditDebugElement.classes['kbq-inline-edit_view']).toBe(true); + }); + + it('should not save when commit() is called while control is invalid', () => { + const fixture = setup(TestWithValidatedControl); + const { componentInstance, debugElement } = fixture; + const inlineEditDebugElement: DebugElement = getInlineEditDebugElement(debugElement); + const spyFn = jest.spyOn(componentInstance, 'update'); + + inlineEditDebugElement.nativeElement.click(); + fixture.detectChanges(); + + componentInstance.control.markAsTouched(); + componentInstance.control.updateValueAndValidity(); + fixture.detectChanges(); + + (inlineEditDebugElement.componentInstance as KbqInlineEdit).commit(); + fixture.detectChanges(); + + expect(spyFn).not.toHaveBeenCalled(); + expect(inlineEditDebugElement.classes['kbq-inline-edit_edit']).toBe(true); + }); + it('should reposition the edit mode overlay when the surrounding layout resizes', () => { const resize$ = new Subject(); const fixture = setup(TestComponent, [ @@ -517,6 +553,86 @@ describe('KbqInlineEdit', () => { expect(document.querySelector(componentCssClasses.selectPanel)).toBeTruthy(); }); + describe('select-style editor', () => { + it('should mark a single select as such and add the select-style panel class while editing', async () => { + const fixture = setup(TestWithSelect); + const { debugElement } = fixture; + const inlineEditDebugElement: DebugElement = getInlineEditDebugElement(debugElement); + + expect(inlineEditDebugElement.classes['kbq-inline-edit_select']).toBe(true); + + inlineEditDebugElement.nativeElement.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(document.querySelector(`${componentCssClasses.panel}.kbq-inline-edit__panel_select`)).toBeTruthy(); + }); + + it('should connect the select panel to the inline-edit host', async () => { + const fixture = setup(TestWithSelect); + const { debugElement } = fixture; + const inlineEditDebugElement: DebugElement = getInlineEditDebugElement(debugElement); + + inlineEditDebugElement.nativeElement.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + const formField = fixture.debugElement.query(By.directive(KbqFormField)).componentInstance as KbqFormField; + + expect(formField.getConnectedOverlayOrigin().nativeElement).toBe(inlineEditDebugElement.nativeElement); + }); + + it('should not treat a multi-select as a select-style editor', async () => { + const fixture = setup(TestWithMultiSelect); + const { debugElement } = fixture; + const inlineEditDebugElement: DebugElement = getInlineEditDebugElement(debugElement); + + expect(inlineEditDebugElement.classes['kbq-inline-edit_select']).toBeFalsy(); + + inlineEditDebugElement.nativeElement.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(document.querySelector(`${componentCssClasses.panel}.kbq-inline-edit__panel_select`)).toBeNull(); + }); + + it('should not override the overlay origin for a non-select control', async () => { + const fixture = setup(TestComponent); + const { debugElement } = fixture; + const inlineEditDebugElement: DebugElement = getInlineEditDebugElement(debugElement); + + inlineEditDebugElement.nativeElement.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + const formField = fixture.debugElement.query(By.directive(KbqFormField)).componentInstance as KbqFormField; + + expect(formField.getConnectedOverlayOrigin().nativeElement).not.toBe(inlineEditDebugElement.nativeElement); + }); + }); + + it('should stay in view mode when commit() is followed by a redundant outside-click save()', async () => { + // A control whose panel is a separate CDK overlay (e.g. kbq-select) makes the overlay's own + // outside-click detection see clicks on that panel as "outside" inline-edit's overlay, so both + // the control's own (selectionChange)-driven commit() and the overlay's outside-click handler + // can fire for the same interaction. Since save() used to be an unconditional toggle, the + // second (redundant) call would flip the mode straight back to 'edit'. + const fixture = setup(TestWithSelectAutoCommit); + const { debugElement } = fixture; + const inlineEditDebugElement: DebugElement = getInlineEditDebugElement(debugElement); + const inlineEdit = inlineEditDebugElement.componentInstance as KbqInlineEdit; + + inlineEditDebugElement.nativeElement.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + inlineEdit.commit(); + (inlineEdit as any).save(); + fixture.detectChanges(); + + expect(inlineEditDebugElement.classes['kbq-inline-edit_view']).toBe(true); + }); + describe('with multiple form fields', () => { it('should emit saved event when both form fields are valid', async () => { const fixture = setup(TestWithMultipleFormFields); @@ -1140,6 +1256,58 @@ export class TestWithSelect extends BaseTestComponent { cancel = jest.fn(); } +@Component({ + selector: 'name', + imports: [ + FormsModule, + KbqInlineEditModule, + KbqOptionModule, + KbqSelectModule + ], + template: ` + +
{{ selected() }}
+ + + @for (option of options; track option) { + {{ option }} + } + + +
+ ` +}) +export class TestWithSelectAutoCommit { + readonly options = Array.from({ length: 5 }).map((_, i) => `Option #${i}`); + readonly selected = model(this.options[0]); +} + +@Component({ + selector: 'name', + imports: [ + FormsModule, + KbqInlineEditModule, + KbqOptionModule, + KbqSelectModule + ], + template: ` + +
{{ selected().join(', ') }}
+ + + @for (option of options; track option) { + {{ option }} + } + + +
+ ` +}) +export class TestWithMultiSelect { + readonly options = Array.from({ length: 5 }).map((_, i) => `Option #${i}`); + readonly selected = model([this.options[0]]); +} + @Component({ selector: 'name', imports: [ReactiveFormsModule, KbqFormFieldModule, KbqInlineEditModule, KbqTextareaModule], diff --git a/packages/components/inline-edit/inline-edit.ts b/packages/components/inline-edit/inline-edit.ts index 5237807afe..a5680f3fee 100644 --- a/packages/components/inline-edit/inline-edit.ts +++ b/packages/components/inline-edit/inline-edit.ts @@ -14,6 +14,7 @@ import { Directive, effect, ElementRef, + forwardRef, inject, input, numberAttribute, @@ -29,15 +30,18 @@ import { AbstractControl, NgControl } from '@angular/forms'; import { KbqButtonModule } from '@koobiq/components/button'; import { isElement, + KBQ_CONNECTED_OVERLAY_ORIGIN, KbqAnimationCurves, KbqAnimationDurations, KbqComponentColors, + KbqConnectedOverlayOriginProvider, kbqInjectA11yLocaleConfiguration, PopUpPlacements } from '@koobiq/components/core'; import { KbqDropdownTrigger } from '@koobiq/components/dropdown'; import { KbqFormField, KbqLabel } from '@koobiq/components/form-field'; import { KbqIcon } from '@koobiq/components/icon'; +import { KbqSelect } from '@koobiq/components/select'; import { KbqTooltipTrigger } from '@koobiq/components/tooltip'; import { merge, skip } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @@ -129,6 +133,7 @@ export class KbqInlineEditMenu { ], templateUrl: './inline-edit.html', styleUrls: ['./inline-edit.scss', './inline-edit-tokens.scss'], + providers: [{ provide: KBQ_CONNECTED_OVERLAY_ORIGIN, useExisting: forwardRef(() => KbqInlineEdit) }], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: { @@ -139,17 +144,16 @@ export class KbqInlineEditMenu { '[class.kbq-inline-edit_with-menu]': '!!menu()', '[class.kbq-inline-edit_disabled]': 'disabled()', '[class.kbq-inline-edit_anchor-focused]': 'anchorFocused()', + '[class.kbq-inline-edit_select]': 'isSingleSelect()', '(click)': 'onClick($event)', '(keydown.enter)': 'onClick($event)', '(keydown.space)': 'onClick($event)' }, - hostDirectives: [ - CdkMonitorFocus - ], + hostDirectives: [CdkMonitorFocus], animations: [KBQ_INLINE_EDIT_ACTION_BUTTONS_ANIMATION], exportAs: 'kbqInlineEdit' }) -export class KbqInlineEdit { +export class KbqInlineEdit implements KbqConnectedOverlayOriginProvider { /** Accessible names for the icon-only save/cancel buttons. */ protected readonly a11yLocaleConfiguration = kbqInjectA11yLocaleConfiguration(); @@ -217,6 +221,19 @@ export class KbqInlineEdit { /** @docs-private */ protected readonly formFieldRefList = contentChildren(KbqFormField, { descendants: true }); + /** @docs-private */ + protected readonly selectRef = contentChild(KbqSelect, { descendants: true }); + /** + * Whether edit mode contains a single-value select. When true, edit mode shows only the + * dropdown panel instead of a bordered field - see the "Select-style editor" example. + * @docs-private + */ + protected readonly isSingleSelect = computed(() => { + const select = this.selectRef(); + + return !!select && !select.multiple && !select.multiline(); + }); + /** @docs-private */ protected overlayOrigin: HTMLElement = this.elementRef.nativeElement; /** @docs-private */ @@ -283,6 +300,21 @@ export class KbqInlineEdit { this.mode.update((mode) => (mode === 'view' ? 'edit' : 'view')); } + /** + * Implements `KbqConnectedOverlayOriginProvider`, letting a nested `KbqFormField`'s control + * anchor its overlay to this element instead of the form-field's own container. + * When no override is needed, the form-field falls back to its default. + * @docs-private + */ + getConnectedOverlayOrigin(): ElementRef | undefined { + return this.isSingleSelect() ? this.elementRef : undefined; + } + + /** Saves the current value and returns to view mode, running the same validation as a normal save. */ + commit(): void { + this.save(); + } + /** @docs-private */ protected onClick(event: Event): void { if (this.disabled() || this.isEditMode() || this.isInteractiveElement(event.target)) return; @@ -331,6 +363,10 @@ export class KbqInlineEdit { /** @docs-private */ protected save($event?: Event): void { + // Guards against a control triggering both its own commit() and the overlay's outside-click handler for the + // same interaction — without this, the second call would toggle back into edit mode. + if (!this.isEditMode()) return; + if (this.isInvalid()) { $event?.stopPropagation(); diff --git a/packages/docs-examples/components/inline-edit/index.ts b/packages/docs-examples/components/inline-edit/index.ts index cfe9fc7eca..72601bbfa3 100644 --- a/packages/docs-examples/components/inline-edit/index.ts +++ b/packages/docs-examples/components/inline-edit/index.ts @@ -10,6 +10,9 @@ import { InlineEditHorizontalListExample } from './inline-edit-horizontal-list/i import { InlineEditMenuExample } from './inline-edit-menu/inline-edit-menu-example'; import { InlineEditOnCleanExample } from './inline-edit-on-clean/inline-edit-on-clean-example'; import { InlineEditOverviewExample } from './inline-edit-overview/inline-edit-overview-example'; +import { InlineEditSelectBasicExample } from './inline-edit-select-basic/inline-edit-select-basic-example'; +import { InlineEditSelectMultilineExample } from './inline-edit-select-multiline/inline-edit-select-multiline-example'; +import { InlineEditSelectExample } from './inline-edit-select/inline-edit-select-example'; import { InlineEditUnfilledExample } from './inline-edit-unfilled/inline-edit-unfilled-example'; import { InlineEditValidationExample } from './inline-edit-validation/inline-edit-validation-example'; import { InlineEditVerticalListExample } from './inline-edit-vertical-list/inline-edit-vertical-list-example'; @@ -27,6 +30,9 @@ export { InlineEditMenuExample, InlineEditOnCleanExample, InlineEditOverviewExample, + InlineEditSelectBasicExample, + InlineEditSelectExample, + InlineEditSelectMultilineExample, InlineEditUnfilledExample, InlineEditValidationExample, InlineEditVerticalListExample, @@ -40,6 +46,9 @@ const EXAMPLES = [ InlineEditDisabledExample, InlineEditHorizontalListExample, InlineEditOnCleanExample, + InlineEditSelectBasicExample, + InlineEditSelectExample, + InlineEditSelectMultilineExample, InlineEditValidationExample, InlineEditCustomHandlerExample, InlineEditVerticalListExample, diff --git a/packages/docs-examples/components/inline-edit/inline-edit-select-basic/inline-edit-select-basic-example.ts b/packages/docs-examples/components/inline-edit/inline-edit-select-basic/inline-edit-select-basic-example.ts new file mode 100644 index 0000000000..d1d4a44933 --- /dev/null +++ b/packages/docs-examples/components/inline-edit/inline-edit-select-basic/inline-edit-select-basic-example.ts @@ -0,0 +1,105 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { KbqOptionModule } from '@koobiq/components/core'; +import { KbqDlModule } from '@koobiq/components/dl'; +import { KbqFormFieldModule, KbqLabel } from '@koobiq/components/form-field'; +import { KbqInlineEditModule } from '@koobiq/components/inline-edit'; +import { KbqSelectModule } from '@koobiq/components/select'; + +const STATUSES: string[] = ['Open', 'In progress', 'Resolved', 'Closed']; +const PRIORITIES: string[] = ['Low', 'Medium', 'High', 'Critical']; + +/** + * @title Inline edit select basic + */ +@Component({ + selector: 'inline-edit-select-basic-example', + imports: [ + ReactiveFormsModule, + KbqInlineEditModule, + KbqFormFieldModule, + KbqLabel, + KbqOptionModule, + KbqSelectModule, + KbqDlModule + ], + template: ` +
+ + Status + +
+ @if (status.value) { + {{ status.value }} + } @else { + {{ notSetLabel }} + } +
+ + + + @for (item of statuses; track item) { + {{ item }} + } + + +
+
+ + + Priority + + +
+ @if (priority.value) { + {{ priority.value }} + } @else { + {{ notSetLabel }} + } +
+ + + + @for (item of priorities; track item) { + {{ item }} + } + + +
+
+
+ `, + styles: ` + .example__section { + width: 200px; + } + + .kbq-dt { + display: inline-flex; + align-items: center; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'layout-column layout-align-center-center layout-gap-l' + } +}) +export class InlineEditSelectBasicExample { + protected readonly notSetLabel = 'Not specified'; + + protected readonly statuses = STATUSES; + protected readonly priorities = PRIORITIES; + + protected readonly status = new FormControl(STATUSES[0]); + protected readonly priority = new FormControl(PRIORITIES[0]); +} diff --git a/packages/docs-examples/components/inline-edit/inline-edit-select-multiline/inline-edit-select-multiline-example.ts b/packages/docs-examples/components/inline-edit/inline-edit-select-multiline/inline-edit-select-multiline-example.ts new file mode 100644 index 0000000000..4e31ef0fb2 --- /dev/null +++ b/packages/docs-examples/components/inline-edit/inline-edit-select-multiline/inline-edit-select-multiline-example.ts @@ -0,0 +1,77 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { KbqOptionModule } from '@koobiq/components/core'; +import { KbqFormFieldModule, KbqLabel } from '@koobiq/components/form-field'; +import { KbqInlineEditModule } from '@koobiq/components/inline-edit'; +import { KbqSelectModule } from '@koobiq/components/select'; + +const COMMENTS: string[] = [ + 'Issue resolved after restarting the affected service. No further action required.', + 'Root cause identified as a misconfigured environment variable, fixed and redeployed.', + 'Duplicate of an existing issue, closing without further changes.', + 'Waiting on additional information from the reporter before further diagnosis.' +]; + +/** + * @title Inline edit select multiline + */ +@Component({ + selector: 'inline-edit-select-multiline-example', + imports: [ + ReactiveFormsModule, + KbqInlineEditModule, + KbqFormFieldModule, + KbqLabel, + KbqOptionModule, + KbqSelectModule + ], + template: ` +
+ + Resolution comment + +
+ @if (control.value) { + {{ control.value }} + } @else { + {{ notSetLabel }} + } +
+ + + + + {{ notSetLabel }} + + @for (comment of comments; track comment) { + {{ comment }} + } + + +
+
+ `, + styles: ` + ::ng-deep .example-inline-select-multiline__options .kbq-option-text { + white-space: normal; + overflow-wrap: break-word; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'layout-column layout-align-center-center' + } +}) +export class InlineEditSelectMultilineExample { + protected readonly notSetLabel = 'Not specified'; + + protected readonly comments = COMMENTS; + + protected readonly control = new FormControl(COMMENTS[0]); +} diff --git a/packages/docs-examples/components/inline-edit/inline-edit-select/inline-edit-select-example.ts b/packages/docs-examples/components/inline-edit/inline-edit-select/inline-edit-select-example.ts new file mode 100644 index 0000000000..96d5c411cb --- /dev/null +++ b/packages/docs-examples/components/inline-edit/inline-edit-select/inline-edit-select-example.ts @@ -0,0 +1,86 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { KbqOptionModule } from '@koobiq/components/core'; +import { KbqFormFieldModule, KbqLabel } from '@koobiq/components/form-field'; +import { KbqInlineEditModule } from '@koobiq/components/inline-edit'; +import { KbqSelectModule } from '@koobiq/components/select'; +import { KbqUserInfo, KbqUsername } from '@koobiq/components/username'; + +const USERS: KbqUserInfo[] = [ + { + firstName: 'Ivan', + lastName: 'Petrov', + login: 'ipetrov', + site: 'Engineering' + }, + { firstName: 'Maria', lastName: 'Sidorova', login: 'msidorova', site: 'Customer support' }, + { firstName: 'Alexey', lastName: 'Smirnov', login: 'asmirnov', site: 'Information security' }, + { firstName: 'Olga', lastName: 'Kuznetsova', login: 'okuznetsova', site: 'Marketing' } +]; + +/** + * @title Inline edit select + */ +@Component({ + selector: 'inline-edit-select-example', + imports: [ + ReactiveFormsModule, + KbqInlineEditModule, + KbqFormFieldModule, + KbqLabel, + KbqOptionModule, + KbqSelectModule, + KbqUsername + ], + template: ` +
+ + Assignee + +
+ @if (control.value) { + + } @else { + {{ notSetLabel }} + } +
+ + + + {{ notSetLabel }} + + @for (user of users; track user) { + + + + } + + +
+
+ `, + styles: ` + .example-inline-select__view { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'layout-column layout-align-center-center' + } +}) +export class InlineEditSelectExample { + protected readonly notSetLabel = 'Not specified'; + + protected readonly users = USERS; + + protected readonly control = new FormControl(this.users[0]); +} diff --git a/packages/docs-examples/example-module.ts b/packages/docs-examples/example-module.ts index e6bf9535d5..75881b1667 100644 --- a/packages/docs-examples/example-module.ts +++ b/packages/docs-examples/example-module.ts @@ -3196,6 +3196,45 @@ export const EXAMPLE_COMPONENTS: {[id: string]: LiveExample} = { "primaryFile": "inline-edit-overview-example.ts", "importPath": "components/inline-edit" }, + "inline-edit-select-basic": { + "packagePath": "components/inline-edit/inline-edit-select-basic", + "title": "Inline edit select basic", + "componentName": "InlineEditSelectBasicExample", + "files": [ + "inline-edit-select-basic-example.ts" + ], + "localImportFiles": [], + "selector": "inline-edit-select-basic-example", + "additionalComponents": [], + "primaryFile": "inline-edit-select-basic-example.ts", + "importPath": "components/inline-edit" + }, + "inline-edit-select-multiline": { + "packagePath": "components/inline-edit/inline-edit-select-multiline", + "title": "Inline edit select multiline", + "componentName": "InlineEditSelectMultilineExample", + "files": [ + "inline-edit-select-multiline-example.ts" + ], + "localImportFiles": [], + "selector": "inline-edit-select-multiline-example", + "additionalComponents": [], + "primaryFile": "inline-edit-select-multiline-example.ts", + "importPath": "components/inline-edit" + }, + "inline-edit-select": { + "packagePath": "components/inline-edit/inline-edit-select", + "title": "Inline edit select", + "componentName": "InlineEditSelectExample", + "files": [ + "inline-edit-select-example.ts" + ], + "localImportFiles": [], + "selector": "inline-edit-select-example", + "additionalComponents": [], + "primaryFile": "inline-edit-select-example.ts", + "importPath": "components/inline-edit" + }, "inline-edit-unfilled": { "packagePath": "components/inline-edit/inline-edit-unfilled", "title": "Inline edit unfilled", @@ -7980,6 +8019,12 @@ return import('@koobiq/docs-examples/components/inline-edit'); case 'inline-edit-on-clean': return import('@koobiq/docs-examples/components/inline-edit'); case 'inline-edit-overview': +return import('@koobiq/docs-examples/components/inline-edit'); + case 'inline-edit-select-basic': +return import('@koobiq/docs-examples/components/inline-edit'); + case 'inline-edit-select-multiline': +return import('@koobiq/docs-examples/components/inline-edit'); + case 'inline-edit-select': return import('@koobiq/docs-examples/components/inline-edit'); case 'inline-edit-unfilled': return import('@koobiq/docs-examples/components/inline-edit'); diff --git a/packages/e2e/routes.ts b/packages/e2e/routes.ts index a67e1b5f34..36320faae6 100644 --- a/packages/e2e/routes.ts +++ b/packages/e2e/routes.ts @@ -46,6 +46,7 @@ import { E2eIconStateAndStyle, E2eIconSvg } from '../components/icon/e2e'; import { E2eInlineEditActionButtons, E2eInlineEditMenuButton, + E2eInlineEditSelectMultiline, E2eInlineEditStates, E2eInlineEditTruncation } from '../components/inline-edit/e2e'; @@ -265,6 +266,7 @@ const components = [ E2eInlineEditMenuButton, E2eInlineEditTruncation, E2eInlineEditActionButtons, + E2eInlineEditSelectMultiline, E2eFormHorizontal, E2eTypographyStyles, E2eTreeTwoLineNode, diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index 73cdfc6be8..399b25c669 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -1079,6 +1079,9 @@ export const KBQ_CONNECTED_OVERLAY_ABOVE_CLASS = "kbq-connected-overlay_above"; // @public export const KBQ_CONNECTED_OVERLAY_BELOW_CLASS = "kbq-connected-overlay_below"; +// @public +export const KBQ_CONNECTED_OVERLAY_ORIGIN: InjectionToken; + // @public (undocumented) export const KBQ_CUSTOM_SCROLL_STRATEGY_PROVIDER: (token: InjectionToken, factory: (overlay: Overlay) => () => ScrollStrategy) => { provide: InjectionToken; @@ -2616,6 +2619,11 @@ export enum KbqComponentColors { Warning = "warning" } +// @public +export interface KbqConnectedOverlayOriginProvider { + getConnectedOverlayOrigin(): ElementRef | undefined; +} + // @public (undocumented) export class KbqDataSizePipe implements PipeTransform { constructor(); diff --git a/tools/public_api_guard/components/inline-edit.api.md b/tools/public_api_guard/components/inline-edit.api.md index ce4d6a46a7..3824f9fd0f 100644 --- a/tools/public_api_guard/components/inline-edit.api.md +++ b/tools/public_api_guard/components/inline-edit.api.md @@ -9,9 +9,11 @@ import { CdkConnectedOverlay } from '@angular/cdk/overlay'; import { ElementRef } from '@angular/core'; import * as i1 from '@angular/cdk/a11y'; import { KbqComponentColors } from '@koobiq/components/core'; +import { KbqConnectedOverlayOriginProvider } from '@koobiq/components/core'; import { KbqDropdownTrigger } from '@koobiq/components/dropdown'; import { KbqFormField } from '@koobiq/components/form-field'; import { KbqLabel } from '@koobiq/components/form-field'; +import { KbqSelect } from '@koobiq/components/select'; import { KbqTooltipTrigger } from '@koobiq/components/tooltip'; import * as _koobiq_components_core from '@koobiq/components/core'; import { PopUpPlacements } from '@koobiq/components/core'; @@ -33,7 +35,7 @@ export class KbqFocusRegionItem { } // @public -export class KbqInlineEdit { +export class KbqInlineEdit implements KbqConnectedOverlayOriginProvider { constructor(); protected readonly a11yLocaleConfiguration: _angular_core.Signal<_koobiq_components_core.KbqA11yLocaleConfiguration>; protected readonly anchorFocused: _angular_core.WritableSignal; @@ -42,16 +44,19 @@ export class KbqInlineEdit { readonly canSaveOnEnter: _angular_core.InputSignal<(event: KeyboardEvent) => boolean>; protected readonly className: _angular_core.Signal; protected readonly colors: typeof KbqComponentColors; + commit(): void; readonly disabled: _angular_core.InputSignalWithTransform; readonly editModeWidth: _angular_core.InputSignalWithTransform; // (undocumented) protected readonly elementRef: ElementRef; protected readonly formFieldRef: _angular_core.Signal; protected readonly formFieldRefList: _angular_core.Signal; + getConnectedOverlayOrigin(): ElementRef | undefined; readonly getValueHandler: _angular_core.InputSignal<(() => unknown) | undefined>; protected readonly hasInteractiveContent: _angular_core.WritableSignal; readonly interactiveSelectors: _angular_core.InputSignal; protected readonly isEditMode: _angular_core.Signal; + protected readonly isSingleSelect: _angular_core.Signal; protected readonly label: _angular_core.Signal; protected readonly menu: _angular_core.Signal; protected readonly mode: _angular_core.WritableSignal; @@ -70,6 +75,7 @@ export class KbqInlineEdit { protected save($event?: Event): void; protected readonly saved: _angular_core.OutputEmitterRef; protected readonly scrollStrategy: _angular_core.WritableSignal; + protected readonly selectRef: _angular_core.Signal; readonly setValueHandler: _angular_core.InputSignal<((value: any) => void) | undefined>; readonly showActions: _angular_core.InputSignalWithTransform; readonly showTooltipOnError: _angular_core.InputSignalWithTransform; @@ -80,7 +86,7 @@ export class KbqInlineEdit { readonly validationTooltip: _angular_core.InputSignal | undefined>; protected readonly viewContainer: _angular_core.Signal>; // (undocumented) - static ɵcmp: _angular_core.ɵɵComponentDeclaration; + static ɵcmp: _angular_core.ɵɵComponentDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; }