Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/components-dev/select/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ import { DEV_OPTIONS } from './mock';
selector: 'dev-examples',
imports: [SelectExamplesModule],
template: `
<select-select-all-example />
<hr />

<select-select-all-search-example />
<hr />

<select-select-all-disabled-example />
<hr />

<select-custom-tag-content-example />
<hr />

Expand Down
9 changes: 9 additions & 0 deletions packages/components-dev/tree-select/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ import { DEV_DATA_OBJECT, devBuildFileTree, DevFileFlatNode, DevFileNode } from
selector: 'dev-examples',
imports: [TreeSelectExamplesModule],
template: `
<tree-select-select-all-example />
<hr />

<tree-select-select-all-search-example />
<hr />

<tree-select-select-all-disabled-example />
<hr />

<tree-select-two-line-option-example />
<hr />

Expand Down
3 changes: 2 additions & 1 deletion packages/components/core/locales/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
KbqClampedTextLocaleConfig,
KbqCodeBlockLocaleConfiguration,
KbqFileUploadLocaleConfig,
KbqSelectLocaleConfiguration,
KbqTimeRangeLocaleConfig
} from './types';

Expand All @@ -22,7 +23,7 @@ export const enUSLocaleData = {
showPassword: 'Show password',
hidePassword: 'Hide password'
} satisfies KbqA11yLocaleConfiguration,
select: { hiddenItemsText: '+{{ number }}' },
select: { hiddenItemsText: '+{{ number }}', selectAll: 'Select all' } satisfies KbqSelectLocaleConfiguration,
datepicker: {
placeholder: 'yyyy-mm-dd',
dateInput: 'yyyy-MM-dd'
Expand Down
6 changes: 5 additions & 1 deletion packages/components/core/locales/es-LA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
KbqClampedTextLocaleConfig,
KbqCodeBlockLocaleConfiguration,
KbqFileUploadLocaleConfig,
KbqSelectLocaleConfiguration,
KbqTimeRangeLocaleConfig
} from './types';

Expand All @@ -22,7 +23,10 @@ export const esLALocaleData = {
showPassword: 'Mostrar la contraseña',
hidePassword: 'Ocultar la contraseña'
} satisfies KbqA11yLocaleConfiguration,
select: { hiddenItemsText: '+{{ number }}' },
select: {
hiddenItemsText: '+{{ number }}',
selectAll: 'Seleccionar todo'
} satisfies KbqSelectLocaleConfiguration,
datepicker: {
placeholder: 'dd/mm/aaaa'
},
Expand Down
6 changes: 5 additions & 1 deletion packages/components/core/locales/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
KbqClampedTextLocaleConfig,
KbqCodeBlockLocaleConfiguration,
KbqFileUploadLocaleConfig,
KbqSelectLocaleConfiguration,
KbqTimeRangeLocaleConfig
} from './types';

Expand All @@ -22,7 +23,10 @@ export const ptBRLocaleData = {
showPassword: 'Mostrar a senha',
hidePassword: 'Ocultar a senha'
} satisfies KbqA11yLocaleConfiguration,
select: { hiddenItemsText: '+{{ number }}' },
select: {
hiddenItemsText: '+{{ number }}',
selectAll: 'Selecionar tudo'
} satisfies KbqSelectLocaleConfiguration,
datepicker: {
placeholder: 'dd/mm/yyyy'
},
Expand Down
3 changes: 2 additions & 1 deletion packages/components/core/locales/ru-RU.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
KbqClampedTextLocaleConfig,
KbqCodeBlockLocaleConfiguration,
KbqFileUploadLocaleConfig,
KbqSelectLocaleConfiguration,
KbqTimeRangeLocaleConfig
} from './types';

Expand All @@ -22,7 +23,7 @@ export const ruRULocaleData = {
showPassword: 'Показать пароль',
hidePassword: 'Скрыть пароль'
} satisfies KbqA11yLocaleConfiguration,
select: { hiddenItemsText: '+{{ number }}' },
select: { hiddenItemsText: '+{{ number }}', selectAll: 'Выбрать все' } satisfies KbqSelectLocaleConfiguration,
datepicker: {
placeholder: 'дд.мм.гггг',
dateInput: 'dd.MM.yyyy'
Expand Down
6 changes: 5 additions & 1 deletion packages/components/core/locales/tk-TM.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
KbqClampedTextLocaleConfig,
KbqCodeBlockLocaleConfiguration,
KbqFileUploadLocaleConfig,
KbqSelectLocaleConfiguration,
KbqTimeRangeLocaleConfig
} from './types';

Expand All @@ -22,7 +23,10 @@ export const tkTMLocaleData = {
showPassword: 'Paroly görkez',
hidePassword: 'Paroly gizle'
} satisfies KbqA11yLocaleConfiguration,
select: { hiddenItemsText: '+{{ number }}' },
select: {
hiddenItemsText: '+{{ number }}',
selectAll: 'Ählisini saýla'
} satisfies KbqSelectLocaleConfiguration,
datepicker: {
placeholder: 'gg.aa.ýý.',
dateInput: 'gg.aa.ýý.'
Expand Down
12 changes: 12 additions & 0 deletions packages/components/core/locales/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ export type KbqActionsPanelLocaleConfiguration = {
closeTooltip: string;
};

/**
* Locale configuration shared by `KbqSelectModule` and `KbqTreeModule`: `hiddenItemsText` is read by
* `KbqSelectModule` (`kbq-select`) only, while `selectAll` is read by both `KbqSelectModule` and
* `KbqTreeModule` (`kbq-tree-selection`, including standalone usage outside `KbqTreeSelectModule`).
*/
export type KbqSelectLocaleConfiguration = {
/** Counter of the selected values that did not fit into the trigger. Supports the `{{ number }}` placeholder. */
hiddenItemsText: string;
/** Label of the "select all" master checkbox rendered by the `selectAll` input. */
selectAll: string;
};

/** Locale configuration for `KbqAppSwitcherModule`: the strings rendered by the app-switcher popup. */
export type KbqAppSwitcherConfiguration = {
/** Placeholder and accessible name of the search field. */
Expand Down
6 changes: 5 additions & 1 deletion packages/components/core/option/option.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,11 @@ export class KbqOption extends KbqOptionBase implements AfterViewChecked, OnDest

/** Ensures the option is selected when activated from the keyboard. */
handleKeydown(event: KeyboardEvent): void {
if (event.keyCode === ENTER || event.keyCode === SPACE) {
// Only claim the event when the option is actually going to react to it: a non-selectable
// or disabled option has nothing to do with ENTER/SPACE, and swallowing the event anyway
// would stop it from reaching an ancestor that might (e.g. a custom row built on top of a
// non-selectable option, like a "select all" master checkbox).
if ((event.keyCode === ENTER || event.keyCode === SPACE) && !this.disabled && this.selectable()) {
this.selectViaInteraction();

// Prevent the page from scrolling down and form submits.
Expand Down
65 changes: 64 additions & 1 deletion packages/components/core/selection/select-all.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { KbqSelectAllAdapter, shouldSelectSearchText, toggleSelectAll } from './select-all';
import { getSelectAllState, KbqSelectAllAdapter, shouldSelectSearchText, toggleSelectAll } from './select-all';

interface TestItem {
disabled: boolean;
Expand Down Expand Up @@ -38,6 +38,18 @@ describe('toggleSelectAll', () => {
expect(changed.length).toBe(1);
});

it('should complete the selection, not deselect, when only some are selected and allowDeselect is true', () => {
const items: TestItem[] = [
{ disabled: false, selected: true },
{ disabled: false, selected: false }
];

const changed = toggleSelectAll(createAdapter(items), { allowDeselect: true });

expect(items.every((item) => item.selected)).toBe(true);
expect(changed.length).toBe(1);
});

it('should deselect all items when every selectable item is already selected and allowDeselect is true', () => {
const items: TestItem[] = [
{ disabled: false, selected: true },
Expand Down Expand Up @@ -104,6 +116,57 @@ describe('toggleSelectAll', () => {
});
});

describe('getSelectAllState', () => {
it('should be unchecked when there are no items', () => {
expect(getSelectAllState(createAdapter([]))).toBe('unchecked');
});

it('should be unchecked when nothing is selected', () => {
const items: TestItem[] = [
{ disabled: false, selected: false },
{ disabled: false, selected: false }
];

expect(getSelectAllState(createAdapter(items))).toBe('unchecked');
});

it('should be indeterminate when only some items are selected', () => {
const items: TestItem[] = [
{ disabled: false, selected: true },
{ disabled: false, selected: false }
];

expect(getSelectAllState(createAdapter(items))).toBe('indeterminate');
});

it('should be checked when every selectable item is selected', () => {
const items: TestItem[] = [
{ disabled: false, selected: true },
{ disabled: false, selected: true }
];

expect(getSelectAllState(createAdapter(items))).toBe('checked');
});

it('should ignore non-selectable items when deciding the state', () => {
const items: TestItem[] = [
{ disabled: true, selected: false },
{ disabled: false, selected: true }
];

expect(getSelectAllState(createAdapter(items))).toBe('checked');
});

it('should be unchecked when every item is non-selectable, whatever their selected state', () => {
const items: TestItem[] = [
{ disabled: true, selected: true },
{ disabled: true, selected: false }
];

expect(getSelectAllState(createAdapter(items))).toBe('unchecked');
});
});

describe('shouldSelectSearchText', () => {
const createInput = (value: string, selectionStart: number | null, selectionEnd: number | null) =>
({ value, selectionStart, selectionEnd }) as HTMLInputElement;
Expand Down
32 changes: 31 additions & 1 deletion packages/components/core/selection/select-all.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { KbqPseudoCheckboxState } from './pseudo-checkbox/pseudo-checkbox';

/**
* Adapter describing how a component reads and writes selection state for a set of items,
* so the shared select-all logic can operate regardless of how selection is stored
Expand All @@ -10,7 +12,12 @@ export interface KbqSelectAllAdapter<T> {
isSelectable: (item: T) => boolean;
/** Current selected state of the item. */
isSelected: (item: T) => boolean;
/** Applies the new selected state to the item. */
/**
* Applies the new selected state to the item. Must be idempotent — a no-op, with no further side
* effects (no emitted events, no notifications) — when `selected` already matches the item's current
* state: {@link toggleSelectAll} calls this for every selectable item, not just the ones whose state
* actually changes, and relies on implementations to absorb the redundant calls silently.
*/
setSelected: (item: T, selected: boolean) => void;
}

Expand Down Expand Up @@ -47,6 +54,29 @@ export function toggleSelectAll<T>(adapter: KbqSelectAllAdapter<T>, options?: Kb
return changed;
}

/**
* State of a "select all" master checkbox over the same items {@link toggleSelectAll} would act on.
*
* Considers only selectable items, so items the toggle cannot reach (disabled, non-selectable) never
* pin the checkbox to `indeterminate`. An empty selectable set reads as `unchecked`: there is nothing
* to select, and `[].every()` being `true` would otherwise report a full selection.
*/
export function getSelectAllState<T>(adapter: KbqSelectAllAdapter<T>): KbqPseudoCheckboxState {
const selectable = adapter.items.filter((item) => adapter.isSelectable(item));

if (selectable.length === 0) {
return 'unchecked';
}

const selected = selectable.filter((item) => adapter.isSelected(item)).length;

if (selected === 0) {
return 'unchecked';
}

return selected === selectable.length ? 'checked' : 'indeterminate';
}

/** Event emitted by the `onSelectAll` outputs when the select-all toggle runs. */
export class KbqSelectAllEvent<T, S = unknown> {
constructor(
Expand Down
66 changes: 66 additions & 0 deletions packages/components/select/e2e.playwright-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,72 @@ test.describe('KbqSelectModule', () => {
});
});

test.describe('E2eSelectSelectAllStates', () => {
const getComponent = (page: Page) => page.getByTestId('e2eSelectSelectAllStates');

test('unchecked, indeterminate and checked', async ({ page }) => {
await page.goto('/E2eSelectSelectAllStates');
const component = getComponent(page);

await component.getByTestId('e2eSelectEmpty').click();
await component.getByTestId('e2eSelectPartial').click();
await component.getByTestId('e2eSelectFull').click();

await expect(component).toHaveScreenshot('06-light.png');
await e2eEnableDarkTheme(page);
await expect(component).toHaveScreenshot('06-dark.png');
});
});

test.describe('select-all keyboard activation (#DS-3969 regression)', () => {
test('toggles via Space/Enter when the row itself holds real focus, not just the search field', async ({
page
}) => {
await page.goto('/E2eSelectSelectAllStates');

const select = page.getByTestId('e2eSelectEmpty');

await select.click();

const row = page.locator('.cdk-overlay-pane .kbq-select__select-all');

await row.waitFor();
// A real click both toggles the row (once) and leaves genuine DOM focus on it —
// the exact focus position where `KbqOption.handleKeydown` used to swallow the
// keydown before it could reach `KbqSelect`'s own panel-level handler.
await row.click();

await expect(row).toHaveAttribute('aria-checked', 'true');

await page.keyboard.press('Space');
await expect(row).toHaveAttribute('aria-checked', 'false');

await page.keyboard.press('Enter');
await expect(row).toHaveAttribute('aria-checked', 'true');
});

test('PAGE_DOWN/PAGE_UP page past the row without skipping it', async ({ page }) => {
await page.goto('/E2eSelectSelectAllStates');

const select = page.getByTestId('e2eSelectEmpty');

await select.click();

const row = page.locator('.cdk-overlay-pane .kbq-select__select-all');
const options = page.locator('.cdk-overlay-pane kbq-option:not(.kbq-select__select-all)');

await row.waitFor();

// Real layout, unlike Jest/JSDOM: PAGE_DOWN's page size is derived from actual measured
// option/container heights, so this is the only environment that can exercise it for real.
await page.keyboard.press('PageDown');
await expect(options.last()).toHaveClass(/kbq-active/);

await page.keyboard.press('PageUp');
await expect(row).toHaveClass(/kbq-active/);
});
});

test.describe('E2eSelectWithSearchAndFooter', () => {
const getComponent = (page: Page) => page.getByTestId('e2eSelectWithSearchAndFooter');
const getSelect = (locator: Locator) => locator.getByTestId('e2eSelect');
Expand Down
Loading
Loading