diff --git a/packages/components-dev/dl/module.ts b/packages/components-dev/dl/module.ts
index 1cf276b09a..5f7ed689ff 100644
--- a/packages/components-dev/dl/module.ts
+++ b/packages/components-dev/dl/module.ts
@@ -1,15 +1,27 @@
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
-import { ThemePalette } from '@koobiq/components/core';
-import { KbqDlModule } from '@koobiq/components/dl';
+import { DlOverviewExample } from 'packages/docs-examples/components/dl/dl-overview/dl-overview-example';
+import { DlResizableExample } from 'packages/docs-examples/components/dl/dl-resizable/dl-resizable-example';
+import { DevThemeToggle } from '../theme-toggle';
+
+@Component({
+ selector: 'dev-examples',
+ imports: [DlResizableExample, DlOverviewExample],
+ template: `
+
+
+
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class DevDocsExamples {}
@Component({
selector: 'dev-app',
- imports: [KbqDlModule],
+ imports: [DevDocsExamples, DevThemeToggle],
templateUrl: './template.html',
styleUrl: './styles.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None
})
-export class DevApp {
- readonly themePalette = ThemePalette;
-}
+export class DevApp {}
diff --git a/packages/components-dev/dl/styles.scss b/packages/components-dev/dl/styles.scss
index 629401c69d..9083a14654 100644
--- a/packages/components-dev/dl/styles.scss
+++ b/packages/components-dev/dl/styles.scss
@@ -3,7 +3,3 @@ dev-app {
max-width: 600px;
padding: var(--kbq-size-s);
}
-
-.kbq-dl {
- border: 1px solid var(--kbq-palette-cyan-50);
-}
diff --git a/packages/components-dev/dl/template.html b/packages/components-dev/dl/template.html
index 80ddd0878f..9eff7f844b 100644
--- a/packages/components-dev/dl/template.html
+++ b/packages/components-dev/dl/template.html
@@ -1,100 +1,5 @@
-
- Тип инцидента
- Вредоносное ПО
+
- Идентификатор
- INC-2022-125-78253
+
- Статус
- Новый
-
- Ответственный
- Иванов Иван
-
- Описание
-
- Здесь нужно добавить очень длинное описание, но Я не знаю, что еще можно сюда добавить, поэтому Вы видите этот
- текст.
-
-
-
-
-
-
- File
- 125 КБ
-
- File
- 125 КБ
-
- File
- 125 КБ
-
- File
- 125 КБ
-
- File
- 125 КБ
-
-
-
-
-
- File
- 125 КБ
-
- File
- 125 КБ
-
- File
- 125 КБ
-
- File
- 125 КБ
-
- File
- 125 КБ
-
-
-
-
-
- File
- 125 КБ
-
- File
- 125 КБ
-
- File
- 125 КБ
-
- File
- 125 КБ
-
- File
- 125 КБ
-
-
-
-
-
- Never show GUIDs
- f3a9583e-346c-4a52-9ddb-252fd34ea9f0
-
-
-
-
-
- Description
-
- In order for your npm command line client to work with Artifactory you will firstly need to set the relevant
- authentication. For getting authentication details run the following command
-
-
-
-
-
-
- Description
- This should be rendered in vertical mode
-
+
diff --git a/packages/components/core/locales/en-US.ts b/packages/components/core/locales/en-US.ts
index 0d04ee509e..17a932a178 100644
--- a/packages/components/core/locales/en-US.ts
+++ b/packages/components/core/locales/en-US.ts
@@ -21,7 +21,8 @@ export const enUSLocaleData = {
nextMonth: 'Next month',
clear: 'Clear',
showPassword: 'Show password',
- hidePassword: 'Hide password'
+ hidePassword: 'Hide password',
+ resizeColumns: 'Resize columns'
} satisfies KbqA11yLocaleConfiguration,
select: { hiddenItemsText: '+{{ number }}', selectAll: 'Select all' } satisfies KbqSelectLocaleConfiguration,
datepicker: {
diff --git a/packages/components/core/locales/es-LA.ts b/packages/components/core/locales/es-LA.ts
index 876878ff45..689fe168b3 100644
--- a/packages/components/core/locales/es-LA.ts
+++ b/packages/components/core/locales/es-LA.ts
@@ -21,7 +21,8 @@ export const esLALocaleData = {
nextMonth: 'Mes siguiente',
clear: 'Borrar',
showPassword: 'Mostrar la contraseña',
- hidePassword: 'Ocultar la contraseña'
+ hidePassword: 'Ocultar la contraseña',
+ resizeColumns: 'Redimensionar columnas'
} satisfies KbqA11yLocaleConfiguration,
select: {
hiddenItemsText: '+{{ number }}',
diff --git a/packages/components/core/locales/pt-BR.ts b/packages/components/core/locales/pt-BR.ts
index 8cad608f86..9c88f8b9c4 100644
--- a/packages/components/core/locales/pt-BR.ts
+++ b/packages/components/core/locales/pt-BR.ts
@@ -21,7 +21,8 @@ export const ptBRLocaleData = {
nextMonth: 'Próximo mês',
clear: 'Apagar',
showPassword: 'Mostrar a senha',
- hidePassword: 'Ocultar a senha'
+ hidePassword: 'Ocultar a senha',
+ resizeColumns: 'Redimensionar colunas'
} satisfies KbqA11yLocaleConfiguration,
select: {
hiddenItemsText: '+{{ number }}',
diff --git a/packages/components/core/locales/ru-RU.ts b/packages/components/core/locales/ru-RU.ts
index 39e7702567..ad0c6d1286 100644
--- a/packages/components/core/locales/ru-RU.ts
+++ b/packages/components/core/locales/ru-RU.ts
@@ -21,7 +21,8 @@ export const ruRULocaleData = {
nextMonth: 'Следующий месяц',
clear: 'Очистить',
showPassword: 'Показать пароль',
- hidePassword: 'Скрыть пароль'
+ hidePassword: 'Скрыть пароль',
+ resizeColumns: 'Изменить ширину колонок'
} satisfies KbqA11yLocaleConfiguration,
select: { hiddenItemsText: '+{{ number }}', selectAll: 'Выбрать все' } satisfies KbqSelectLocaleConfiguration,
datepicker: {
diff --git a/packages/components/core/locales/tk-TM.ts b/packages/components/core/locales/tk-TM.ts
index d99ba46f63..638b5aaadb 100644
--- a/packages/components/core/locales/tk-TM.ts
+++ b/packages/components/core/locales/tk-TM.ts
@@ -21,7 +21,8 @@ export const tkTMLocaleData = {
nextMonth: 'Indiki aý',
clear: 'Arassala',
showPassword: 'Paroly görkez',
- hidePassword: 'Paroly gizle'
+ hidePassword: 'Paroly gizle',
+ resizeColumns: 'Sütünleriň giňligini üýtget'
} satisfies KbqA11yLocaleConfiguration,
select: {
hiddenItemsText: '+{{ number }}',
diff --git a/packages/components/core/locales/types.ts b/packages/components/core/locales/types.ts
index 7af586899d..1dc921fdeb 100644
--- a/packages/components/core/locales/types.ts
+++ b/packages/components/core/locales/types.ts
@@ -29,6 +29,8 @@ export type KbqA11yLocaleConfiguration = {
showPassword: string;
/** Password form field button that masks the password. */
hidePassword: string;
+ /** Separator that resizes the columns of a description list. */
+ resizeColumns: string;
};
/** Locale configuration for `KbqCodeBlockModule`. */
diff --git a/packages/components/dl/dl-tokens.scss b/packages/components/dl/dl-tokens.scss
index 002ca606e5..797858069b 100644
--- a/packages/components/dl/dl-tokens.scss
+++ b/packages/components/dl/dl-tokens.scss
@@ -3,7 +3,15 @@
--kbq-description-list-size-horizontal-gap-vertical: var(--kbq-size-l);
--kbq-description-list-size-vertical-content-gap-vertical: var(--kbq-size-3xs);
--kbq-description-list-size-vertical-gap-vertical: var(--kbq-size-m);
+ --kbq-description-list-resizer-line-width: 1px;
+ --kbq-description-list-resizer-hit-area-width: calc(
+ 2 * var(--kbq-size-s) + var(--kbq-description-list-resizer-line-width)
+ );
+ --kbq-description-list-resizer-transition-duration: 0.2s;
/* THEME TOKENS */
--kbq-description-list-term-color: var(--kbq-foreground-contrast-secondary);
--kbq-description-list-description-color: var(--kbq-foreground-contrast);
+ --kbq-description-list-resizer-color: var(--kbq-line-contrast-less);
+ --kbq-description-list-resizer-state-hover-color: var(--kbq-line-contrast-fade);
+ --kbq-description-list-resizer-state-active-color: var(--kbq-states-line-focus-theme);
}
diff --git a/packages/components/dl/dl.component.spec.ts b/packages/components/dl/dl.component.spec.ts
index a81e62c62f..34558df6e1 100644
--- a/packages/components/dl/dl.component.spec.ts
+++ b/packages/components/dl/dl.component.spec.ts
@@ -1,9 +1,26 @@
-import { Provider, Type } from '@angular/core';
-import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { Directionality } from '@angular/cdk/bidi';
+import { SharedResizeObserver } from '@angular/cdk/observers/private';
+import { Injectable, Provider, Type } from '@angular/core';
+import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing';
+import { enUSLocaleData, kbqA11yLocaleConfigurationProvider } from '@koobiq/components/core';
+import { BehaviorSubject, EMPTY, Observable } from 'rxjs';
import { KbqDlAlign, KbqDlComponent } from './dl.component';
+/** `SharedResizeObserver` stand-in: the real one never emits in jsdom, where `ResizeObserver` is a no-op stub. */
+@Injectable()
+class MockResizeObserver extends SharedResizeObserver {
+ changes = new BehaviorSubject([]);
+
+ override observe(_target: Element, _options?: ResizeObserverOptions): Observable {
+ return this.changes.asObservable();
+ }
+}
+
const createComponent = (component: Type, providers: Provider[] = []): ComponentFixture => {
- TestBed.configureTestingModule({ imports: [component], providers });
+ TestBed.configureTestingModule({
+ imports: [component],
+ providers: [{ provide: SharedResizeObserver, useClass: MockResizeObserver }, ...providers]
+ });
const fixture = TestBed.createComponent(component);
fixture.autoDetectChanges();
@@ -12,6 +29,9 @@ const createComponent = (component: Type, providers: Provider[] = []): Com
};
const getDlElement = ({ nativeElement }: ComponentFixture): HTMLElement => nativeElement;
+const getResizeHandle = ({ nativeElement }: ComponentFixture): HTMLElement | null => {
+ return nativeElement.querySelector('.kbq-dl__resize-handle');
+};
describe(KbqDlComponent.name, () => {
it('should use start alignment by default', () => {
@@ -56,4 +76,337 @@ describe(KbqDlComponent.name, () => {
expect(getDlElement(fixture).classList).toContain('kbq-dl_wide');
expect(getDlElement(fixture).classList).toContain('kbq-dl_vertical');
});
+
+ it('should render resize handle only when resizing is enabled for horizontal layout', () => {
+ const fixture = createComponent(KbqDlComponent);
+
+ expect(getResizeHandle(fixture)).toBeNull();
+
+ fixture.componentRef.setInput('columnResizable', true);
+ fixture.detectChanges();
+
+ expect(getResizeHandle(fixture)).not.toBeNull();
+
+ fixture.componentRef.setInput('vertical', true);
+ fixture.detectChanges();
+
+ expect(getResizeHandle(fixture)).toBeNull();
+ });
+
+ it('should constrain the resize track to the configured widths once a width is set', () => {
+ const fixture = createComponent(KbqDlComponent);
+
+ fixture.componentRef.setInput('columnResizable', true);
+ fixture.componentRef.setInput('vertical', false);
+ fixture.componentRef.setInput('columnMinWidth', 120);
+ fixture.componentRef.setInput('remainingColumnMinWidth', 200);
+ fixture.componentRef.setInput('columnWidth', 300);
+ Object.defineProperty(getDlElement(fixture), 'clientWidth', { configurable: true, value: 600 });
+ fixture.detectChanges();
+
+ const resizeTrack = getDlElement(fixture).querySelector('.kbq-dl__resize-track')!;
+
+ expect(resizeTrack.style.width).toBe('300px');
+ expect(resizeTrack.style.minWidth).toBe('120px');
+ expect(resizeTrack.style.maxWidth).toBe('400px');
+ });
+
+ it('should expose the column min width as a CSS variable so the resting grid honors it', () => {
+ const fixture = createComponent(KbqDlComponent);
+
+ fixture.componentRef.setInput('columnResizable', true);
+ fixture.componentRef.setInput('vertical', false);
+ fixture.componentRef.setInput('columnMinWidth', 200);
+ fixture.detectChanges();
+
+ expect(getDlElement(fixture).style.getPropertyValue('--kbq-description-list-column-min-width')).toBe('200px');
+ });
+
+ it('should not constrain the resting track before a width is set, so the separator stays on the column border', () => {
+ const fixture = createComponent(KbqDlComponent);
+
+ fixture.componentRef.setInput('columnResizable', true);
+ fixture.componentRef.setInput('vertical', false);
+ // A min width wider than the natural column must not inflate the resting track into the second column.
+ fixture.componentRef.setInput('columnMinWidth', 200);
+ fixture.componentRef.setInput('remainingColumnMinWidth', 200);
+ Object.defineProperty(getDlElement(fixture), 'clientWidth', { configurable: true, value: 600 });
+ fixture.detectChanges();
+
+ const resizeTrack = getDlElement(fixture).querySelector('.kbq-dl__resize-track')!;
+
+ expect(resizeTrack.style.width).toBe('');
+ expect(resizeTrack.style.minWidth).toBe('');
+ expect(resizeTrack.style.maxWidth).toBe('');
+ });
+
+ it('should resize the first column with keyboard and expose separator semantics', () => {
+ const fixture = createComponent(KbqDlComponent);
+
+ fixture.componentRef.setInput('columnResizable', true);
+ fixture.componentRef.setInput('vertical', false);
+ fixture.componentRef.setInput('columnMinWidth', 96);
+ fixture.detectChanges();
+
+ const resizeHandle = getResizeHandle(fixture)!;
+
+ resizeHandle.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
+ fixture.detectChanges();
+
+ // The configured min width (96) is also the max here (host width is 0 in jsdom), so it clamps to 96.
+ expect(fixture.componentInstance.columnWidth()).toBe(96);
+ expect(resizeHandle.getAttribute('role')).toBe('separator');
+ expect(resizeHandle.getAttribute('aria-orientation')).toBe('vertical');
+ expect(getDlElement(fixture).classList).toContain('kbq-dl_resized');
+ });
+
+ it('should collapse to the minimum width on double click, then reset on the next one', () => {
+ const fixture = createComponent(KbqDlComponent);
+
+ fixture.componentRef.setInput('columnResizable', true);
+ fixture.componentRef.setInput('vertical', false);
+ fixture.componentRef.setInput('columnMinWidth', 120);
+ fixture.componentRef.setInput('columnWidth', 200);
+ Object.defineProperty(getDlElement(fixture), 'clientWidth', { configurable: true, value: 600 });
+ fixture.detectChanges();
+
+ const resizeHandle = getResizeHandle(fixture)!;
+
+ resizeHandle.dispatchEvent(new MouseEvent('dblclick'));
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.columnWidth()).toBe(120);
+ expect(getDlElement(fixture).classList).toContain('kbq-dl_resized');
+
+ resizeHandle.dispatchEvent(new MouseEvent('dblclick'));
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.columnWidth()).toBeNull();
+ expect(getDlElement(fixture).classList).not.toContain('kbq-dl_resized');
+ });
+
+ it('should update the shared column width on pointer drag', () => {
+ const fixture = createComponent(KbqDlComponent);
+
+ fixture.componentRef.setInput('columnResizable', true);
+ fixture.componentRef.setInput('vertical', false);
+ fixture.detectChanges();
+
+ const resizeTrack = getDlElement(fixture).querySelector('.kbq-dl__resize-track')!;
+ const resizeHandle = getResizeHandle(fixture)!;
+
+ Object.defineProperty(getDlElement(fixture), 'clientWidth', { configurable: true, value: 600 });
+ // The resizer captures the base size via `getResizableSize()`; force its border-box branch to report 120.
+ resizeTrack.style.boxSizing = 'border-box';
+ Object.defineProperty(resizeTrack, 'getBoundingClientRect', {
+ configurable: true,
+ value: () => ({ width: 120, height: 0 }) as DOMRect
+ });
+
+ resizeHandle.dispatchEvent(new MouseEvent('pointerdown', { clientX: 120 }));
+ document.dispatchEvent(new MouseEvent('pointermove', { buttons: 1, clientX: 200 }));
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.columnWidth()).toBe(200);
+ });
+
+ it('should reverse horizontal resize direction in RTL', () => {
+ const fixture = createComponent(KbqDlComponent, [
+ { provide: Directionality, useValue: { value: 'rtl', change: EMPTY } }
+ ]);
+
+ fixture.componentRef.setInput('columnResizable', true);
+ fixture.componentRef.setInput('vertical', false);
+ fixture.detectChanges();
+
+ const resizeTrack = getDlElement(fixture).querySelector('.kbq-dl__resize-track')!;
+
+ Object.defineProperty(getDlElement(fixture), 'clientWidth', { configurable: true, value: 600 });
+ Object.defineProperty(resizeTrack, 'clientWidth', { configurable: true, value: 200 });
+
+ getResizeHandle(fixture)!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.columnWidth()).toBe(208);
+ });
+
+ it('should apply the col-resize cursor to the separator', () => {
+ const fixture = createComponent(KbqDlComponent);
+
+ fixture.componentRef.setInput('columnResizable', true);
+ fixture.componentRef.setInput('vertical', false);
+ fixture.detectChanges();
+
+ expect(getResizeHandle(fixture)!.style.cursor).toBe('col-resize');
+ });
+
+ it('should fall back to the localized separator name for the aria-label', () => {
+ const fixture = createComponent(KbqDlComponent, [
+ kbqA11yLocaleConfigurationProvider({ ...enUSLocaleData.a11y, resizeColumns: 'Resize the columns' })
+ ]);
+
+ fixture.componentRef.setInput('columnResizable', true);
+ fixture.componentRef.setInput('vertical', false);
+ fixture.detectChanges();
+
+ expect(getResizeHandle(fixture)!.getAttribute('aria-label')).toBe('Resize the columns');
+ });
+
+ it('should prefer the provided resizer aria-label over the localized default', () => {
+ const fixture = createComponent(KbqDlComponent, [
+ kbqA11yLocaleConfigurationProvider({ ...enUSLocaleData.a11y, resizeColumns: 'Localized default' })
+ ]);
+
+ fixture.componentRef.setInput('columnResizable', true);
+ fixture.componentRef.setInput('vertical', false);
+ fixture.componentRef.setInput('resizerAriaLabel', 'Custom label');
+ fixture.detectChanges();
+
+ expect(getResizeHandle(fixture)!.getAttribute('aria-label')).toBe('Custom label');
+ });
+
+ it('should clamp a negative column min width to zero', () => {
+ const fixture = createComponent(KbqDlComponent);
+
+ fixture.componentRef.setInput('columnResizable', true);
+ fixture.componentRef.setInput('vertical', false);
+ fixture.componentRef.setInput('columnMinWidth', -50);
+ fixture.detectChanges();
+
+ expect(getResizeHandle(fixture)!.getAttribute('aria-valuemin')).toBe('0');
+ });
+
+ it('should auto-detect the vertical layout from the host width via the resize observer', fakeAsync(() => {
+ const fixture = createComponent(KbqDlComponent);
+
+ // The layout decision reads `getClientRects()`; report a width below the default `verticalBreakpoint` (400).
+ Object.defineProperty(getDlElement(fixture), 'getClientRects', {
+ configurable: true,
+ value: () => [{ width: 320 } as DOMRect]
+ });
+
+ // Flush the resize-observer debounce so the initial `startWith(null)` emission runs `updateLayout`.
+ tick(100);
+ fixture.detectChanges();
+
+ expect(getDlElement(fixture).classList).toContain('kbq-dl_vertical');
+ expect(getResizeHandle(fixture)).toBeNull();
+ flush();
+ }));
+
+ it('should keep the horizontal layout when the host is wider than verticalBreakpoint', fakeAsync(() => {
+ const fixture = createComponent(KbqDlComponent);
+
+ fixture.componentRef.setInput('columnResizable', true);
+ Object.defineProperty(getDlElement(fixture), 'getClientRects', {
+ configurable: true,
+ value: () => [{ width: 800 } as DOMRect]
+ });
+
+ tick(100);
+ fixture.detectChanges();
+
+ expect(getDlElement(fixture).classList).not.toContain('kbq-dl_vertical');
+ expect(getResizeHandle(fixture)).not.toBeNull();
+ flush();
+ }));
+
+ it('should let an explicit vertical input override the auto-detection', fakeAsync(() => {
+ const fixture = createComponent(KbqDlComponent);
+
+ // Host is narrow (auto-detection would choose vertical), but the explicit input forces horizontal.
+ fixture.componentRef.setInput('vertical', false);
+ Object.defineProperty(getDlElement(fixture), 'getClientRects', {
+ configurable: true,
+ value: () => [{ width: 200 } as DOMRect]
+ });
+
+ tick(100);
+ fixture.detectChanges();
+
+ expect(getDlElement(fixture).classList).not.toContain('kbq-dl_vertical');
+ flush();
+ }));
+
+ it('should re-evaluate the layout against verticalBreakpoint on every resize while in auto mode', fakeAsync(() => {
+ const fixture = createComponent(KbqDlComponent);
+ const observer = TestBed.inject(SharedResizeObserver) as MockResizeObserver;
+
+ let width = 800;
+
+ Object.defineProperty(getDlElement(fixture), 'getClientRects', {
+ configurable: true,
+ value: () => [{ width } as DOMRect]
+ });
+
+ tick(100);
+ fixture.detectChanges();
+ expect(getDlElement(fixture).classList).not.toContain('kbq-dl_vertical');
+
+ // Shrink below the threshold: the layout must switch to vertical, not stay locked to the first measurement.
+ width = 300;
+ observer.changes.next([]);
+ tick(100);
+ fixture.detectChanges();
+ expect(getDlElement(fixture).classList).toContain('kbq-dl_vertical');
+
+ // Grow back above the threshold: it must switch back to horizontal.
+ width = 800;
+ observer.changes.next([]);
+ tick(100);
+ fixture.detectChanges();
+ expect(getDlElement(fixture).classList).not.toContain('kbq-dl_vertical');
+ flush();
+ }));
+
+ it('should honor a custom verticalBreakpoint as the vertical-layout threshold', fakeAsync(() => {
+ const fixture = createComponent(KbqDlComponent);
+
+ // 600px is above the default threshold (400) but below the custom one (700), so only a working
+ // `verticalBreakpoint` input makes the layout switch to vertical here.
+ fixture.componentRef.setInput('verticalBreakpoint', 700);
+ Object.defineProperty(getDlElement(fixture), 'getClientRects', {
+ configurable: true,
+ value: () => [{ width: 600 } as DOMRect]
+ });
+
+ tick(100);
+ fixture.detectChanges();
+
+ expect(getDlElement(fixture).classList).toContain('kbq-dl_vertical');
+ flush();
+ }));
+
+ it('should stay horizontal when the host is wider than a custom verticalBreakpoint', fakeAsync(() => {
+ const fixture = createComponent(KbqDlComponent);
+
+ fixture.componentRef.setInput('verticalBreakpoint', 500);
+ Object.defineProperty(getDlElement(fixture), 'getClientRects', {
+ configurable: true,
+ value: () => [{ width: 600 } as DOMRect]
+ });
+
+ tick(100);
+ fixture.detectChanges();
+
+ expect(getDlElement(fixture).classList).not.toContain('kbq-dl_vertical');
+ flush();
+ }));
+
+ it('should still honor the deprecated minWidth alias as the vertical-layout threshold', fakeAsync(() => {
+ const fixture = createComponent(KbqDlComponent);
+
+ // 600px is above the default (400) but below the alias value, so only a working `minWidth` makes it vertical.
+ fixture.componentRef.setInput('minWidth', 700);
+ Object.defineProperty(getDlElement(fixture), 'getClientRects', {
+ configurable: true,
+ value: () => [{ width: 600 } as DOMRect]
+ });
+
+ tick(100);
+ fixture.detectChanges();
+
+ expect(getDlElement(fixture).classList).toContain('kbq-dl_vertical');
+ flush();
+ }));
});
diff --git a/packages/components/dl/dl.component.ts b/packages/components/dl/dl.component.ts
index e9b477c886..4e699447cc 100644
--- a/packages/components/dl/dl.component.ts
+++ b/packages/components/dl/dl.component.ts
@@ -1,97 +1,293 @@
+import { Direction, Directionality } from '@angular/cdk/bidi';
+import { SharedResizeObserver } from '@angular/cdk/observers/private';
import { Platform } from '@angular/cdk/platform';
import {
- AfterContentInit,
+ afterNextRender,
+ booleanAttribute,
+ ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
+ computed,
+ contentChildren,
+ DestroyRef,
ElementRef,
inject,
- Input,
input,
- OnDestroy,
+ model,
+ numberAttribute,
+ signal,
+ viewChild,
ViewEncapsulation
} from '@angular/core';
-import { Subject, Subscription } from 'rxjs';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { KBQ_WINDOW, kbqInjectA11yLocaleConfiguration, kbqInjectNativeElement } from '@koobiq/components/core';
+import { KbqResizable, KbqResizer, KbqResizerDirection, KbqResizerSizeChangeEvent } from '@koobiq/components/resizer';
import { debounceTime, startWith } from 'rxjs/operators';
/** Supported alignment values for description list items. */
export type KbqDlAlign = 'start' | 'center' | 'end';
@Component({
- selector: 'kbq-dl',
+ selector: 'kbq-dt',
+ template: '',
+ encapsulation: ViewEncapsulation.None,
+ host: {
+ class: 'kbq-dt'
+ }
+})
+export class KbqDtComponent {}
+
+@Component({
+ selector: 'kbq-dd',
template: '',
+ encapsulation: ViewEncapsulation.None,
+ host: {
+ class: 'kbq-dd'
+ }
+})
+export class KbqDdComponent {}
+
+@Component({
+ selector: 'kbq-dl',
+ imports: [KbqResizable, KbqResizer],
+ template: `
+
+
+ @if (resizerVisible()) {
+
+ }
+ `,
styleUrls: ['dl.scss', 'dl-tokens.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: {
class: 'kbq-dl',
- '[class.kbq-dl_vertical]': 'vertical',
+ '[class.kbq-dl_vertical]': 'isVertical()',
'[class.kbq-dl_wide]': 'wide()',
+ '[class.kbq-dl_resizable]': 'resizerVisible()',
+ '[class.kbq-dl_resized]': 'columnWidth() !== null',
'[class.kbq-dl_vertical-align-center]': "verticalAlign() === 'center'",
'[class.kbq-dl_vertical-align-end]': "verticalAlign() === 'end'",
'[class.kbq-dl_horizontal-align-center]': "horizontalAlign() === 'center'",
'[class.kbq-dl_horizontal-align-end]': "horizontalAlign() === 'end'",
- '(window:resize)': 'resizeStream.next($event)'
+ '[style.--kbq-description-list-column-width.px]': 'columnWidth()',
+ '[style.--kbq-description-list-column-min-width.px]': 'normalizedColumnMinWidth()'
}
})
-export class KbqDlComponent implements AfterContentInit, OnDestroy {
- readonly minWidth = input(400);
+export class KbqDlComponent {
+ /** Host width in pixels at or below which the list auto-switches to the vertical layout. */
+ readonly verticalBreakpoint = input(400, { transform: numberAttribute });
+
+ /**
+ * Host width in pixels at or below which the list auto-switches to the vertical layout.
+ * @deprecated The name is misleading (it is a breakpoint, not a min width). Use `verticalBreakpoint` instead.
+ * Will be removed in a future major release. When both are set, `minWidth` takes precedence.
+ */
+ readonly minWidth = input();
+
+ /** Whether the list uses the wide two-column layout. */
readonly wide = input(false);
+
+ /** Whether the columns can be resized by dragging the separator between them. */
+ readonly columnResizable = input(false, { transform: booleanAttribute });
+
+ /** Current width of the first (resizable) column in pixels; `null` restores the default column ratio. */
+ readonly columnWidth = model(null);
+
+ /** Minimum width of the first (resizable) column in pixels; defaults to the term width measured after render. */
+ readonly columnMinWidth = input(undefined);
+
+ /** Minimum width retained for the remaining column in pixels; defaults to the term width measured after render. */
+ readonly remainingColumnMinWidth = input(undefined);
+
+ /** Accessible name of the column resize separator; falls back to the localized default when omitted. */
+ readonly resizerAriaLabel = input(undefined);
+
/** Vertical alignment of `kbq-dt` and `kbq-dd` items. */
readonly verticalAlign = input('start');
+
/** Horizontal alignment of `kbq-dt` and `kbq-dd` items. */
readonly horizontalAlign = input('start');
- // TODO: Skipped for migration because:
- // Your application code writes to the input. This prevents migration.
- @Input() vertical: boolean | null = null;
- readonly resizeStream = new Subject();
- private readonly resizeDebounceInterval: number = 100;
+ /** Forces the vertical layout; `null` lets the list decide based on `verticalBreakpoint`. */
+ readonly vertical = input(null);
+
+ /** @docs-private */
+ protected readonly resizeDirection = signal([1, 0]);
+
+ /** Auto-detected vertical layout, re-evaluated from the host width on resize while `vertical` is not set. */
+ private readonly autoVertical = signal(null);
+
+ /** @docs-private Effective vertical layout, combining the explicit `vertical` input and the auto-detection. */
+ protected readonly isVertical = computed(() => this.vertical() ?? this.autoVertical() ?? false);
+
+ /** @docs-private Whether the column resize separator is currently rendered. */
+ protected readonly resizerVisible = computed(() => this.columnResizable() && !this.isVertical());
+
+ /** Term column width measured after the first render; the default minimum for both columns when unset. */
+ private readonly measuredColumnWidth = signal(0);
+
+ /** Effective minimum width of the first column: the input, or the measured term width when not provided. */
+ private readonly resolvedColumnMinWidth = computed(() => this.columnMinWidth() ?? this.measuredColumnWidth());
+
+ /** Effective minimum width of the remaining column: the input, or the measured term width when not provided. */
+ private readonly resolvedRemainingColumnMinWidth = computed(
+ () => this.remainingColumnMinWidth() ?? this.measuredColumnWidth()
+ );
+
+ /** @docs-private Term column minimum width, clamped to a non-negative value. */
+ protected readonly normalizedColumnMinWidth = computed(() => Math.max(0, this.resolvedColumnMinWidth()));
+
+ protected get maxColumnWidth(): number {
+ if (!this.platform.isBrowser) return this.normalizedColumnMinWidth();
+
+ const hostWidth = this.nativeElement.clientWidth;
+ const columnGap = parseFloat(this.window.getComputedStyle(this.nativeElement).columnGap) || 0;
+
+ return Math.max(
+ this.normalizedColumnMinWidth(),
+ hostWidth - columnGap - Math.max(0, this.resolvedRemainingColumnMinWidth())
+ );
+ }
- private resizeSubscription = Subscription.EMPTY;
+ protected get currentColumnWidth(): number {
+ if (!this.platform.isBrowser) return this.normalizedColumnMinWidth();
- private readonly elementRef = inject>(ElementRef);
+ return Math.round(this.resizeTrack()?.nativeElement.clientWidth || this.normalizedColumnMinWidth());
+ }
+
+ private readonly resizeDebounceInterval: number = 100;
+ private readonly keyboardResizeStep: number = 8;
+
+ private readonly nativeElement = kbqInjectNativeElement();
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly platform = inject(Platform);
+ private readonly window = inject(KBQ_WINDOW);
+ private readonly destroyRef = inject(DestroyRef);
+ private readonly resizeObserver = inject(SharedResizeObserver);
+ private readonly directionality = inject(Directionality, { optional: true });
+ private readonly a11yLocaleConfiguration = kbqInjectA11yLocaleConfiguration();
+ private readonly resizeTrack = viewChild>('resizeTrack');
+ private readonly terms = contentChildren(KbqDtComponent, { read: ElementRef });
+
+ /** @docs-private Resolved accessible name of the resize separator. */
+ protected readonly resolvedResizerAriaLabel = computed(
+ () => this.resizerAriaLabel() || this.a11yLocaleConfiguration().resizeColumns
+ );
+
+ constructor() {
+ this.updateResizeDirection(this.directionality?.value);
+ this.directionality?.change.pipe(takeUntilDestroyed()).subscribe((direction) => {
+ this.updateResizeDirection(direction);
+ });
+
+ // `afterNextRender` runs in the browser only, so no explicit platform guard is needed here.
+ afterNextRender(() => {
+ this.measureColumnWidth();
+
+ this.resizeObserver
+ .observe(this.nativeElement)
+ .pipe(startWith(null), debounceTime(this.resizeDebounceInterval), takeUntilDestroyed(this.destroyRef))
+ .subscribe(() => this.updateLayout());
+ });
+ }
+
+ /** Captures the rendered width of the term column, used as the default minimum when no widths are provided. */
+ private measureColumnWidth(): void {
+ const term = this.terms()[0]?.nativeElement;
+
+ if (term) this.measuredColumnWidth.set(Math.round(term.getBoundingClientRect().width));
+ }
+
+ /** @docs-private */
+ protected handleColumnResize({ width }: KbqResizerSizeChangeEvent): void {
+ this.setColumnWidth(width);
+ }
- ngAfterContentInit(): void {
- if (this.vertical !== null || !this.platform.isBrowser) {
- return;
+ /** @docs-private */
+ protected handleResizeKeydown(event: KeyboardEvent): void {
+ let width: number | null = null;
+
+ if (event.key === 'Home') width = this.normalizedColumnMinWidth();
+ if (event.key === 'End') width = this.maxColumnWidth;
+
+ if (event.key === 'ArrowLeft') {
+ width = this.currentColumnWidth - this.resizeDirection()[0] * this.keyboardResizeStep;
}
- this.resizeSubscription = this.resizeStream
- .pipe(startWith(null), debounceTime(this.resizeDebounceInterval))
- .subscribe(this.updateState);
+ if (event.key === 'ArrowRight') {
+ width = this.currentColumnWidth + this.resizeDirection()[0] * this.keyboardResizeStep;
+ }
+
+ if (width === null) return;
+
+ event.preventDefault();
+ this.setColumnWidth(width);
}
- ngOnDestroy() {
- this.resizeSubscription.unsubscribe();
+ /** @docs-private */
+ protected handleResizeDblClick(event: MouseEvent): void {
+ event.preventDefault();
+
+ // First double-click collapses the first column to its minimum width; a second one restores the default ratio.
+ if (this.columnWidth() === this.normalizedColumnMinWidth()) {
+ this.columnWidth.set(null);
+ } else {
+ this.setColumnWidth(this.normalizedColumnMinWidth());
+ }
}
- private readonly updateState = (): void => {
- const domRect = this.elementRef.nativeElement.getClientRects()[0];
- const width = domRect?.width || 0;
+ private updateLayout(): void {
+ // While `vertical` is not set explicitly, re-evaluate the layout against the breakpoint on every resize.
+ if (this.vertical() === null) {
+ const domRect = this.nativeElement.getClientRects()[0];
+ const width = domRect?.width || 0;
+ // `minWidth` is the deprecated alias of `verticalBreakpoint`; honor it when a consumer still sets it.
+ const breakpoint = this.minWidth() ?? this.verticalBreakpoint();
+
+ this.autoVertical.set(width <= breakpoint);
+ }
- this.vertical = width <= this.minWidth();
+ if (this.columnWidth() !== null && !this.isVertical()) this.setColumnWidth(this.columnWidth()!);
+ // Signal writes above already schedule change detection, but `maxColumnWidth` and `currentColumnWidth` read
+ // live layout geometry rather than signals: a resize changes their values without touching any signal, so the
+ // OnPush view must be told to re-check for the `aria-valuemax`/`aria-valuenow`/track-size bindings to refresh.
this.changeDetectorRef.markForCheck();
- };
-}
+ }
-@Component({
- selector: 'kbq-dt',
- template: '',
- encapsulation: ViewEncapsulation.None,
- host: {
- class: 'kbq-dt'
+ private setColumnWidth(width: number): void {
+ if (!Number.isFinite(width)) return;
+
+ const constrainedWidth = Math.min(this.maxColumnWidth, Math.max(this.normalizedColumnMinWidth(), width));
+
+ if (constrainedWidth !== this.columnWidth()) this.columnWidth.set(constrainedWidth);
}
-})
-export class KbqDtComponent {}
-@Component({
- selector: 'kbq-dd',
- template: '',
- encapsulation: ViewEncapsulation.None,
- host: {
- class: 'kbq-dd'
+ private updateResizeDirection(direction: Direction | undefined): void {
+ this.resizeDirection.set(direction === 'rtl' ? [-1, 0] : [1, 0]);
}
-})
-export class KbqDdComponent {}
+}
diff --git a/packages/components/dl/dl.en.md b/packages/components/dl/dl.en.md
index ea569c267c..ecaa9d3100 100644
--- a/packages/components/dl/dl.en.md
+++ b/packages/components/dl/dl.en.md
@@ -2,6 +2,12 @@
+### Resizable
+
+Enabled with the `columnResizable` flag. A separator appears on the column border: drag it with the mouse or the keyboard (arrows, `Home`/`End`); a double-click collapses the first column to its minimum, a second one restores the default layout. Control the width via `[(columnWidth)]`, and set the bounds with `columnMinWidth` and `remainingColumnMinWidth`.
+
+
+
### Small (adaptive)
diff --git a/packages/components/dl/dl.ru.md b/packages/components/dl/dl.ru.md
index ea569c267c..c7f6f2f138 100644
--- a/packages/components/dl/dl.ru.md
+++ b/packages/components/dl/dl.ru.md
@@ -1,15 +1,21 @@
-### Default (adaptive)
+### По умолчанию (адаптивный)
-### Small (adaptive)
+### Изменение ширины колонок
+
+Включается флагом `columnResizable`. На границе колонок появляется разделитель: тяните его мышью или клавишами (стрелки, `Home`/`End`); двойной клик сворачивает первую колонку до минимума, повторный — сбрасывает к раскладке по умолчанию. Ширину можно контролировать через `[(columnWidth)]`, а границы задать через `columnMinWidth` и `remainingColumnMinWidth`.
+
+
+
+### Компактный (адаптивный)
-### Horizontal
+### Горизонтальный
-### Vertical
+### Вертикальный
diff --git a/packages/components/dl/dl.scss b/packages/components/dl/dl.scss
index dbba61c004..ca196d7e5b 100644
--- a/packages/components/dl/dl.scss
+++ b/packages/components/dl/dl.scss
@@ -1,7 +1,8 @@
-@use './dl-theme' as *;
+@use './dl-theme' as theme;
.kbq-dl {
display: grid;
+ position: relative;
column-gap: var(--kbq-description-list-size-horizontal-content-gap-horizontal);
row-gap: var(--kbq-description-list-size-horizontal-gap-vertical);
@@ -31,9 +32,91 @@
grid-column: 2 / span 3;
}
+ // Before the first resize, keep the term column at least as wide as its configured minimum so the separator
+ // rests on the real column border and dragging starts from a width that already satisfies the minimum.
+ &.kbq-dl_resizable:not(.kbq-dl_resized):not(.kbq-dl_vertical) {
+ grid-template-columns:
+ minmax(var(--kbq-description-list-column-min-width), 1fr)
+ repeat(3, minmax(0, 1fr));
+ }
+
+ &.kbq-dl_resized:not(.kbq-dl_vertical) {
+ grid-template-columns:
+ var(--kbq-description-list-column-width)
+ repeat(3, minmax(0, 1fr));
+ }
+
+ .kbq-dl__resize-track {
+ position: absolute;
+ inset-block: 0;
+ inset-inline-start: 0;
+ grid-column: 1 / 2;
+ width: 100%;
+ pointer-events: none;
+ z-index: 1;
+ }
+
+ .kbq-dl__resize-handle {
+ position: absolute;
+ inset-block: 0;
+ inset-inline-end: calc(
+ (
+ var(--kbq-description-list-size-horizontal-content-gap-horizontal) +
+ var(--kbq-description-list-resizer-hit-area-width)
+ ) /
+ -2
+ );
+ width: var(--kbq-description-list-resizer-hit-area-width);
+ pointer-events: auto;
+ touch-action: none;
+
+ // The resize line, centered inside the hit area. Hidden until the list is hovered or the separator is focused.
+ &::after {
+ position: absolute;
+ inset-block: 0;
+ inset-inline: 0;
+ width: var(--kbq-description-list-resizer-line-width);
+ margin-inline: auto;
+ background-color: var(--kbq-description-list-resizer-color);
+ content: '';
+ opacity: 0;
+ transition:
+ opacity var(--kbq-description-list-resizer-transition-duration) ease-in-out,
+ background-color var(--kbq-description-list-resizer-transition-duration) ease-in-out;
+ }
+
+ &:hover::after {
+ background-color: var(--kbq-description-list-resizer-state-hover-color);
+ }
+
+ &:focus-visible::after,
+ &:active::after {
+ background-color: var(--kbq-description-list-resizer-state-active-color);
+ }
+
+ // Keep the separator visible while it is focused or being dragged, even without a pointer hover.
+ &:focus-visible::after,
+ &:active::after {
+ opacity: 1;
+ }
+ }
+
+ // Reveal the separator on hover of the list.
+ &:hover .kbq-dl__resize-handle::after {
+ opacity: 1;
+ }
+
&.kbq-dl_wide {
grid-template-columns: repeat(2, 1fr);
+ &.kbq-dl_resizable:not(.kbq-dl_resized):not(.kbq-dl_vertical) {
+ grid-template-columns: minmax(var(--kbq-description-list-column-min-width), 1fr) minmax(0, 1fr);
+ }
+
+ &.kbq-dl_resized:not(.kbq-dl_vertical) {
+ grid-template-columns: var(--kbq-description-list-column-width) minmax(0, 1fr);
+ }
+
.kbq-dt {
grid-column: 1;
}
@@ -59,5 +142,5 @@
}
}
-@include kbq-dl-theme();
-@include kbq-dl-typography();
+@include theme.kbq-dl-theme();
+@include theme.kbq-dl-typography();
diff --git a/packages/components/dl/e2e.playwright-spec.ts b/packages/components/dl/e2e.playwright-spec.ts
index 06156b1efb..2f3ea0ac12 100644
--- a/packages/components/dl/e2e.playwright-spec.ts
+++ b/packages/components/dl/e2e.playwright-spec.ts
@@ -1,4 +1,4 @@
-import { expect, Page, test } from '@playwright/test';
+import { expect, Locator, Page, test } from '@playwright/test';
import { e2eEnableDarkTheme } from '../../e2e/utils';
test.describe('KbqDlModule', () => {
@@ -12,4 +12,86 @@ test.describe('KbqDlModule', () => {
await expect(getComponent(page)).toHaveScreenshot('01-dark.png');
});
});
+
+ test.describe('E2eDlResizable', () => {
+ const getComponent = (page: Page) => page.getByTestId('e2eDlResizableConfigured');
+ const getAutoComponent = (page: Page) => page.getByTestId('e2eDlResizableAuto');
+ const getList = (block: Locator) => block.locator('.kbq-dl');
+ const getSeparator = (block: Locator) => block.getByRole('separator');
+ const getFirstTerm = (block: Locator) => block.locator('.kbq-dt').first();
+
+ const resizedClass = /(?:^|\s)kbq-dl_resized(?:\s|$)/;
+
+ test('should widen the first column when the separator is dragged to the right', async ({ page }) => {
+ await page.goto('/E2eDlResizable');
+
+ const block = getComponent(page);
+ const separator = getSeparator(block);
+ const term = getFirstTerm(block);
+
+ const startWidth = (await term.boundingBox())!.width;
+
+ await separator.hover();
+ const box = (await separator.boundingBox())!;
+ const startX = box.x + box.width / 2;
+ const startY = box.y + box.height / 2;
+ const dragOffset = 60;
+
+ await page.mouse.down();
+ await page.mouse.move(startX + dragOffset, startY, { steps: 5 });
+ await page.mouse.up();
+
+ const endWidth = (await term.boundingBox())!.width;
+
+ expect(endWidth).toBeGreaterThan(startWidth);
+ expect(endWidth).toBeCloseTo(startWidth + dragOffset, 0);
+ await expect(getList(block)).toHaveClass(resizedClass);
+ });
+
+ test('should collapse to the minimum on double click and reset on the second', async ({ page }) => {
+ await page.goto('/E2eDlResizable');
+
+ const block = getComponent(page);
+ const separator = getSeparator(block);
+
+ await separator.dblclick();
+ await expect(getList(block)).toHaveClass(resizedClass);
+ await expect(separator).toHaveAttribute('aria-valuenow', '120');
+
+ await separator.dblclick();
+ await expect(getList(block)).not.toHaveClass(resizedClass);
+ });
+
+ test('should resize the first column with the keyboard', async ({ page }) => {
+ await page.goto('/E2eDlResizable');
+
+ const block = getComponent(page);
+ const separator = getSeparator(block);
+ const term = getFirstTerm(block);
+
+ const startWidth = (await term.boundingBox())!.width;
+
+ await separator.focus();
+ await page.keyboard.press('ArrowRight');
+
+ await expect(getList(block)).toHaveClass(resizedClass);
+ expect((await term.boundingBox())!.width).toBeGreaterThanOrEqual(startWidth);
+ });
+
+ test('should default the column minimums to the measured term width when they are not set', async ({
+ page
+ }) => {
+ await page.goto('/E2eDlResizable');
+
+ const block = getAutoComponent(page);
+ const separator = getSeparator(block);
+ const termWidth = Math.round((await getFirstTerm(block).boundingBox())!.width);
+
+ // With no `columnMinWidth`/`remainingColumnMinWidth`, the resizer falls back to the measured term width.
+ const ariaValueMin = Number(await separator.getAttribute('aria-valuemin'));
+
+ expect(ariaValueMin).toBeGreaterThan(0);
+ expect(ariaValueMin).toBeCloseTo(termWidth, 0);
+ });
+ });
});
diff --git a/packages/components/dl/e2e.ts b/packages/components/dl/e2e.ts
index 2804c49564..5d5fe8629f 100644
--- a/packages/components/dl/e2e.ts
+++ b/packages/components/dl/e2e.ts
@@ -8,7 +8,7 @@ import { KbqDlModule } from './dl.module';
template: `
-
+
dt
dd
dt
@@ -22,7 +22,7 @@ import { KbqDlModule } from './dl.module';
-
+
dt
dd
dt
@@ -36,7 +36,7 @@ import { KbqDlModule } from './dl.module';
-
+
center
Badge
@@ -48,7 +48,7 @@ import { KbqDlModule } from './dl.module';
-
+
Multiline description term
end
end
@@ -58,7 +58,7 @@ import { KbqDlModule } from './dl.module';
-
+
center
dd
center
@@ -68,7 +68,7 @@ import { KbqDlModule } from './dl.module';
-
+
end
definition
end
@@ -112,3 +112,43 @@ import { KbqDlModule } from './dl.module';
}
})
export class E2eDlStates {}
+
+@Component({
+ selector: 'e2e-dl-resizable',
+ imports: [KbqDlModule],
+ template: `
+
+
+ Incident type
+ Malware infection
+ Identifier
+ INC-2026-125-78253
+ Description
+ A long description that keeps the second column filled while the first one is resized.
+
+
+
+
+
+ Incident type
+ Malware infection
+ Identifier
+ INC-2026-125-78253
+ Description
+ A long description that keeps the second column filled while the first one is resized.
+
+
+ `,
+ styles: `
+ :host {
+ display: block;
+ width: 600px;
+ padding: var(--kbq-size-m);
+ }
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ 'data-testid': 'e2eDlResizable'
+ }
+})
+export class E2eDlResizable {}
diff --git a/packages/components/resizer/resizer.spec.ts b/packages/components/resizer/resizer.spec.ts
index f44aa5aed6..4b09e3da2a 100644
--- a/packages/components/resizer/resizer.spec.ts
+++ b/packages/components/resizer/resizer.spec.ts
@@ -51,7 +51,7 @@ const windowStub = (boxSizing: 'content-box' | 'border-box'): Provider => ({
standalone: true,
template: `
`
})
@@ -60,6 +60,7 @@ export class TestResizer {
readonly resizable = viewChild.required(KbqResizable);
readonly direction = signal([1, 0]);
+ readonly cursor = signal(null);
readonly sizeChange = jest.fn();
}
@@ -84,6 +85,31 @@ describe(KbqResizer.name, () => {
expect(getResizerElement(fixture).style.cursor).toBe(cursor);
});
+ it('should override the direction-derived cursor with the `cursor` input', () => {
+ const fixture = createComponent(TestResizer);
+
+ fixture.componentInstance.direction.set([1, 0]);
+ fixture.componentInstance.cursor.set('col-resize');
+ fixture.detectChanges();
+
+ expect(getResizerElement(fixture).style.cursor).toBe('col-resize');
+ });
+
+ it('should fall back to the direction-derived cursor when the `cursor` input is empty', () => {
+ const fixture = createComponent(TestResizer);
+
+ fixture.componentInstance.direction.set([0, 1]);
+ fixture.componentInstance.cursor.set('col-resize');
+ fixture.detectChanges();
+
+ expect(getResizerElement(fixture).style.cursor).toBe('col-resize');
+
+ fixture.componentInstance.cursor.set(null);
+ fixture.detectChanges();
+
+ expect(getResizerElement(fixture).style.cursor).toBe('ns-resize');
+ });
+
it('should emit sizeChange event when resizing', async () => {
const fixture = createComponent(TestResizer);
diff --git a/packages/components/resizer/resizer.ts b/packages/components/resizer/resizer.ts
index c69c3e7461..c5d9262532 100644
--- a/packages/components/resizer/resizer.ts
+++ b/packages/components/resizer/resizer.ts
@@ -70,7 +70,7 @@ export class KbqResizable {
selector: '[kbqResizer]',
host: {
class: 'kbq-resizer',
- '[style.cursor]': 'cursor()'
+ '[style.cursor]': 'resolvedCursor()'
},
exportAs: 'kbqResizer'
})
@@ -94,6 +94,11 @@ export class KbqResizer {
*/
readonly direction = input.required({ alias: 'kbqResizer' });
+ /**
+ * Overrides the cursor that is otherwise derived from the resizing direction.
+ */
+ readonly cursor = input(null);
+
/**
* Emits the new size of the element after resizing.
*/
@@ -102,7 +107,11 @@ export class KbqResizer {
/**
* @docs-private
*/
- protected readonly cursor = computed(() => {
+ protected readonly resolvedCursor = computed(() => {
+ const cursor = this.cursor();
+
+ if (cursor) return cursor;
+
const [x, y] = this.direction();
if (x === 0 && y === 0) return 'default';
diff --git a/packages/docs-examples/components/dl/dl-resizable/dl-resizable-example.ts b/packages/docs-examples/components/dl/dl-resizable/dl-resizable-example.ts
new file mode 100644
index 0000000000..28376aae52
--- /dev/null
+++ b/packages/docs-examples/components/dl/dl-resizable/dl-resizable-example.ts
@@ -0,0 +1,29 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { KbqBadgeModule } from '@koobiq/components/badge';
+import { KbqDlModule } from '@koobiq/components/dl';
+
+/**
+ * @title Description list resizable example
+ */
+@Component({
+ selector: 'dl-resizable-example',
+ imports: [KbqDlModule, KbqBadgeModule],
+ template: `
+
+ Incident type
+
+ DDoS
+
+ Identifier
+ INC-2026-125-78253
+ Description
+
+ In a distributed denial-of-service attack (DDoS attack), the incoming traffic flooding the victim
+ originates from many different sources. More sophisticated strategies are required to mitigate this type
+ of attack; simply attempting to block a single source is insufficient as there are multiple sources.
+
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class DlResizableExample {}
diff --git a/packages/docs-examples/components/dl/index.ts b/packages/docs-examples/components/dl/index.ts
index 78941140a7..49cfe7c087 100644
--- a/packages/docs-examples/components/dl/index.ts
+++ b/packages/docs-examples/components/dl/index.ts
@@ -1,16 +1,24 @@
import { NgModule } from '@angular/core';
import { DlHorizontalOverviewExample } from './dl-horizontal-overview/dl-horizontal-overview-example';
import { DlOverviewExample } from './dl-overview/dl-overview-example';
+import { DlResizableExample } from './dl-resizable/dl-resizable-example';
import { DlSmallExample } from './dl-small/dl-small-example';
import { DlVerticalOverviewExample } from './dl-vertical-overview/dl-vertical-overview-example';
-export { DlHorizontalOverviewExample, DlOverviewExample, DlSmallExample, DlVerticalOverviewExample };
+export {
+ DlHorizontalOverviewExample,
+ DlOverviewExample,
+ DlResizableExample,
+ DlSmallExample,
+ DlVerticalOverviewExample
+};
const EXAMPLES = [
DlOverviewExample,
DlHorizontalOverviewExample,
DlVerticalOverviewExample,
- DlSmallExample
+ DlSmallExample,
+ DlResizableExample
];
@NgModule({
diff --git a/packages/docs-examples/components/filter-bar/filter-bar-readonly-pipes/filter-bar-readonly-pipes-example.ts b/packages/docs-examples/components/filter-bar/filter-bar-readonly-pipes/filter-bar-readonly-pipes-example.ts
index d8bc116a30..fd4a90a378 100644
--- a/packages/docs-examples/components/filter-bar/filter-bar-readonly-pipes/filter-bar-readonly-pipes-example.ts
+++ b/packages/docs-examples/components/filter-bar/filter-bar-readonly-pipes/filter-bar-readonly-pipes-example.ts
@@ -41,7 +41,7 @@ const createSearchPipe = (): KbqPipe => ({
-
+
Пользователь
rturov
diff --git a/packages/e2e/routes.ts b/packages/e2e/routes.ts
index 9a4b5faadd..e86f6c522e 100644
--- a/packages/e2e/routes.ts
+++ b/packages/e2e/routes.ts
@@ -25,7 +25,7 @@ import { E2eCodeBlockStates } from '../components/code-block/e2e';
import { E2eContentPanelScrollOverflow, E2eContentPanelState } from '../components/content-panel/e2e';
import { E2eDatepickerPositioning, E2eDatepickerStates } from '../components/datepicker/e2e';
import { E2eDividerStateAndStyle } from '../components/divider/e2e';
-import { E2eDlStates } from '../components/dl/e2e';
+import { E2eDlResizable, E2eDlStates } from '../components/dl/e2e';
import {
E2eDropdownNestedLtr,
E2eDropdownNestedRtl,
@@ -161,6 +161,7 @@ const components = [
E2eEmptyStateStateAndStyle,
E2eCodeBlockStates,
E2eDlStates,
+ E2eDlResizable,
E2eAlertStateAndStyle,
E2eDividerStateAndStyle,
E2eButtonToggleStates,
diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md
index 59ec202267..8728796ca7 100644
--- a/tools/public_api_guard/components/core.api.md
+++ b/tools/public_api_guard/components/core.api.md
@@ -451,6 +451,7 @@ export const enUSLocaleData: {
clear: string;
showPassword: string;
hidePassword: string;
+ resizeColumns: string;
};
select: {
hiddenItemsText: string;
@@ -716,6 +717,7 @@ export const esLALocaleData: {
clear: string;
showPassword: string;
hidePassword: string;
+ resizeColumns: string;
};
select: {
hiddenItemsText: string;
@@ -1223,6 +1225,7 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): {
clear: string;
showPassword: string;
hidePassword: string;
+ resizeColumns: string;
};
select: {
hiddenItemsText: string;
@@ -1463,6 +1466,7 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): {
clear: string;
showPassword: string;
hidePassword: string;
+ resizeColumns: string;
};
select: {
hiddenItemsText: string;
@@ -1698,6 +1702,7 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): {
clear: string;
showPassword: string;
hidePassword: string;
+ resizeColumns: string;
};
select: {
hiddenItemsText: string;
@@ -1938,6 +1943,7 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): {
clear: string;
showPassword: string;
hidePassword: string;
+ resizeColumns: string;
};
select: {
hiddenItemsText: string;
@@ -2175,6 +2181,7 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): {
clear: string;
showPassword: string;
hidePassword: string;
+ resizeColumns: string;
};
select: {
hiddenItemsText: string;
@@ -2469,6 +2476,7 @@ export type KbqA11yLocaleConfiguration = {
clear: string;
showPassword: string;
hidePassword: string;
+ resizeColumns: string;
};
// @public
@@ -4455,6 +4463,7 @@ export const ptBRLocaleData: {
clear: string;
showPassword: string;
hidePassword: string;
+ resizeColumns: string;
};
select: {
hiddenItemsText: string;
@@ -4905,6 +4914,7 @@ export const ruRULocaleData: {
clear: string;
showPassword: string;
hidePassword: string;
+ resizeColumns: string;
};
select: {
hiddenItemsText: string;
@@ -5266,6 +5276,7 @@ export const tkTMLocaleData: {
clear: string;
showPassword: string;
hidePassword: string;
+ resizeColumns: string;
};
select: {
hiddenItemsText: string;
diff --git a/tools/public_api_guard/components/dl.api.md b/tools/public_api_guard/components/dl.api.md
index 5c80ab79eb..8652a41ba8 100644
--- a/tools/public_api_guard/components/dl.api.md
+++ b/tools/public_api_guard/components/dl.api.md
@@ -4,62 +4,72 @@
```ts
-import { AfterContentInit } from '@angular/core';
-import * as i0 from '@angular/core';
+import * as _angular_core from '@angular/core';
import * as i1 from '@angular/cdk/a11y';
import * as i2 from '@angular/cdk/platform';
-import { OnDestroy } from '@angular/core';
-import { Subject } from 'rxjs';
+import { KbqResizerDirection } from '@koobiq/components/resizer';
+import { KbqResizerSizeChangeEvent } from '@koobiq/components/resizer';
// @public (undocumented)
export class KbqDdComponent {
// (undocumented)
- static ɵcmp: i0.ɵɵComponentDeclaration;
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration;
// (undocumented)
- static ɵfac: i0.ɵɵFactoryDeclaration;
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration;
}
// @public
export type KbqDlAlign = 'start' | 'center' | 'end';
// @public (undocumented)
-export class KbqDlComponent implements AfterContentInit, OnDestroy {
- readonly horizontalAlign: i0.InputSignal;
+export class KbqDlComponent {
+ constructor();
+ readonly columnMinWidth: _angular_core.InputSignal;
+ readonly columnResizable: _angular_core.InputSignalWithTransform;
+ readonly columnWidth: _angular_core.ModelSignal;
// (undocumented)
- readonly minWidth: i0.InputSignal;
+ protected get currentColumnWidth(): number;
+ protected handleColumnResize(input: KbqResizerSizeChangeEvent): void;
+ protected handleResizeDblClick(event: MouseEvent): void;
+ protected handleResizeKeydown(event: KeyboardEvent): void;
+ readonly horizontalAlign: _angular_core.InputSignal;
+ protected readonly isVertical: _angular_core.Signal;
// (undocumented)
- ngAfterContentInit(): void;
+ protected get maxColumnWidth(): number;
+ // @deprecated
+ readonly minWidth: _angular_core.InputSignal;
+ protected readonly normalizedColumnMinWidth: _angular_core.Signal;
+ readonly remainingColumnMinWidth: _angular_core.InputSignal;
+ protected readonly resizeDirection: _angular_core.WritableSignal;
+ readonly resizerAriaLabel: _angular_core.InputSignal;
+ protected readonly resizerVisible: _angular_core.Signal;
+ protected readonly resolvedResizerAriaLabel: _angular_core.Signal;
+ readonly vertical: _angular_core.InputSignal;
+ readonly verticalAlign: _angular_core.InputSignal;
+ readonly verticalBreakpoint: _angular_core.InputSignalWithTransform;
+ readonly wide: _angular_core.InputSignal;
// (undocumented)
- ngOnDestroy(): void;
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration;
// (undocumented)
- readonly resizeStream: Subject;
- // (undocumented)
- vertical: boolean | null;
- readonly verticalAlign: i0.InputSignal;
- // (undocumented)
- readonly wide: i0.InputSignal;
- // (undocumented)
- static ɵcmp: i0.ɵɵComponentDeclaration;
- // (undocumented)
- static ɵfac: i0.ɵɵFactoryDeclaration;
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration;
}
// @public (undocumented)
export class KbqDlModule {
// (undocumented)
- static ɵfac: i0.ɵɵFactoryDeclaration;
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration;
// (undocumented)
- static ɵinj: i0.ɵɵInjectorDeclaration;
+ static ɵinj: _angular_core.ɵɵInjectorDeclaration;
// (undocumented)
- static ɵmod: i0.ɵɵNgModuleDeclaration;
+ static ɵmod: _angular_core.ɵɵNgModuleDeclaration;
}
// @public (undocumented)
export class KbqDtComponent {
// (undocumented)
- static ɵcmp: i0.ɵɵComponentDeclaration;
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration;
// (undocumented)
- static ɵfac: i0.ɵɵFactoryDeclaration;
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration;
}
// (No @packageDocumentation comment for this package)
diff --git a/tools/public_api_guard/components/resizer.api.md b/tools/public_api_guard/components/resizer.api.md
index ef7a31e2a6..dcb1910736 100644
--- a/tools/public_api_guard/components/resizer.api.md
+++ b/tools/public_api_guard/components/resizer.api.md
@@ -18,11 +18,12 @@ export class KbqResizable {
// @public
export class KbqResizer {
constructor();
- protected readonly cursor: i0.Signal<"default" | "ns-resize" | "ew-resize" | "nwse-resize" | "nesw-resize">;
+ readonly cursor: i0.InputSignal;
readonly direction: i0.InputSignal;
+ protected readonly resolvedCursor: i0.Signal;
readonly sizeChange: i0.OutputEmitterRef;
// (undocumented)
- static ɵdir: i0.ɵɵDirectiveDeclaration;
+ static ɵdir: i0.ɵɵDirectiveDeclaration;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration;
}