Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
29 changes: 28 additions & 1 deletion packages/components-dev/accordion/module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,37 @@
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
import { KbqAccordionModule } from '@koobiq/components/accordion';
import { KbqIconModule } from '@koobiq/components/icon';
import { AccordionExamplesModule } from 'packages/docs-examples/components/accordion';

@Component({
selector: 'dev-examples',
imports: [AccordionExamplesModule],
template: `
<accordion-overview-example />
<hr />
<accordion-states-example />
<hr />
<accordion-sections-example />
<hr />
<accordion-inactive-section-example />
<hr />
<accordion-header-example />
<hr />
<accordion-content-example />
<hr />
<accordion-interactive-elements-example />
<hr />
<accordion-in-section-example />
<hr />
<accordion-in-panel-example />
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DevDocsExamples {}

@Component({
selector: 'dev-app',
imports: [KbqAccordionModule, KbqIconModule],
imports: [KbqAccordionModule, KbqIconModule, DevDocsExamples],
templateUrl: './template.html',
styleUrls: ['./styles.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
Expand Down
4 changes: 4 additions & 0 deletions packages/components-dev/accordion/template.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
<dev-examples />

<hr />

<kbq-accordion useStateSaving [type]="'multiple'">
<kbq-accordion-item>
<kbq-accordion-header>
Expand Down
6 changes: 5 additions & 1 deletion packages/components/accordion/_accordion-theme.scss
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
@use '../core/styles/common/tokens' as *;

@mixin kbq-accordion-theme() {
// Only the trigger owns the item's focus ring. `:focus-within` would also fire for header
// actions and for controls inside the expanded content, ringing a section the user is merely
// typing in. Both shapes are matched because `<kbq-accordion-header>` is optional.
.kbq-accordion.cdk-keyboard-focused {
& .kbq-accordion-item:focus-within {
& .kbq-accordion-item:has(> .kbq-accordion-trigger:focus),
& .kbq-accordion-item:has(> .kbq-accordion-header .kbq-accordion-trigger:focus) {
border-color: var(--kbq-accordion-item-states-focus-border-color);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import { KbqAccordionItem } from './accordion-item';
'[attr.id]': 'contentId',
'[attr.role]': '"region"',
'[attr.hidden]': 'hidden() ? "" : null',
// `hidden` alone does not take the collapsed content out of the tab order: the host keeps an
// explicit `display: block` so the height can animate, which overrides `[hidden]`'s
// `display: none`. Without `inert`, every control inside a closed section stays focusable in
// a zero-height, clipped box.
'[attr.inert]': 'hidden() ? "" : null',
'[attr.aria-labelledby]': 'triggerId',

'[attr.data-state]': 'item.dataState',
Expand Down
15 changes: 15 additions & 0 deletions packages/components/accordion/accordion-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { KbqAccordionItem } from './accordion-item';
class: 'kbq-accordion-header',
'[attr.role]': '"heading"',
'[attr.aria-level]': 'accordion.level()',
'[attr.aria-labelledby]': 'labelledBy',
'[attr.data-state]': 'item.dataState',
'[attr.data-disabled]': 'item.disabled',
'[attr.data-orientation]': 'item.orientation'
Expand All @@ -18,4 +19,18 @@ export class KbqAccordionHeader {
protected readonly item = inject(KbqAccordionItem);
/** @docs-private */
protected readonly accordion = inject(KbqAccordion);

/**
* Names the heading after the trigger alone.
*
* A `role="heading"` takes its name from its content, so the header actions sitting beside the
* trigger would otherwise append their own labels to it — a section titled "Profile" would be
* announced as "Profile Run More" when navigating by heading. Pointing at the trigger yields the
* announcement the WAI-ARIA APG gets by keeping the button the only element in the heading,
* which this component cannot do because the header is also the row that lays the actions out.
* @docs-private
*/
protected get labelledBy(): string | null {
return this.item.trigger()?.triggerId ?? null;
}
}
7 changes: 5 additions & 2 deletions packages/components/accordion/accordion-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,11 @@ export type KbqAccordionItemState = 'open' | 'closed';
}
})
export class KbqAccordionItem implements OnDestroy {
/** @docs-private */
protected readonly accordion = inject(KbqAccordion);
/**
* The accordion this item belongs to — always the nearest one, because it is injected.
* The accordion reads it back to tell its own items apart from a nested accordion's.
*/
readonly accordion = inject(KbqAccordion);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Должен ли фокус заходить в контент item в закрытом состоянии?

Если нет, можно для KbqAccordionContentDirective добавить атрибут inert

/** @docs-private */
protected readonly changeDetectorRef = inject(ChangeDetectorRef);
/** @docs-private */
Expand Down
2 changes: 2 additions & 0 deletions packages/components/accordion/accordion-tokens.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
--kbq-accordion-size-item-header-variant-hug-padding: var(--kbq-size-xs) var(--kbq-size-s) var(--kbq-size-xs)
var(--kbq-size-m);
--kbq-accordion-size-item-content-padding: 0px var(--kbq-size-m) var(--kbq-size-s) var(--kbq-size-m);
--kbq-accordion-size-item-header-actions-gap: var(--kbq-size-3xs);
--kbq-accordion-size-item-header-actions-padding-right: var(--kbq-size-s);
/* THEME TOKENS */
--kbq-accordion-item-default-background: transparent;
--kbq-accordion-item-default-text-color: var(--kbq-foreground-contrast);
Expand Down
21 changes: 20 additions & 1 deletion packages/components/accordion/accordion-trigger.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import { KbqAccordionItem } from './accordion-item';
'[attr.data-disabled]': 'item.disabled',
'[attr.data-orientation]': 'item.orientation',
'(click)': 'onClick()',
'(focus)': 'onFocus()'
'(focus)': 'onFocus()',
'(keydown)': 'onKeydown($event)'
}
})
export class KbqAccordionTriggerDirective {
Expand Down Expand Up @@ -54,6 +55,24 @@ export class KbqAccordionTriggerDirective {
this.accordion.setActiveItem(this.item);
}

/**
* Routes keyboard interaction to the accordion, but only while the trigger itself is focused.
*
* A `keydown` targets the focused element, so header actions placed next to the trigger and
* controls inside the expanded content keep their own keys — the accordion no longer toggles the
* section on Enter/Space nor moves focus to another header on the arrow keys. The `target` check
* additionally covers focusable content mistakenly nested inside the trigger.
*/
onKeydown(event: KeyboardEvent): void {
if (event.target !== this.nativeElement) return;

// The key manager can lag behind when focus was moved programmatically rather than by the
// user, in which case `onFocus` never ran.
this.accordion.setActiveItem(this.item);

this.accordion.keydownHandler(event);
}

/** @docs-private */
focus() {
this.nativeElement.focus();
Expand Down
6 changes: 6 additions & 0 deletions packages/components/accordion/accordion-trigger.scss
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
margin-right: var(--kbq-size-s);
}

// @deprecated Actions nested inside the trigger are focusable elements inside a `role="button"`.
// Put them in a `.kbq-accordion-header__actions` sibling of the trigger instead.
& .kbq-accordion-trigger__action {
padding: var(--kbq-size-xxs) var(--kbq-size-xxs) var(--kbq-size-3xs);
margin-bottom: calc(-1 * #{var(--kbq-size-3xs)});
Expand Down Expand Up @@ -61,4 +63,8 @@

button.kbq-accordion-trigger {
flex: 1;

// A flex item floors at its min-content width by default, which would push the header actions
// past the item's right edge instead of letting a long label give way.
min-width: 0;
}
6 changes: 6 additions & 0 deletions packages/components/accordion/accordion.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ This area can contain any type of content.

<!-- example(accordion-content) -->

##### Interactive Elements

Buttons, dropdown menus and form controls can be placed in the section header next to the trigger, as well as inside the content area. Place them **next to** the trigger, never inside it: the trigger is a `role="button"`, and nesting focusable elements in it breaks accessibility. Enter and Space toggle the section only while the trigger itself is focused.

<!-- example(accordion-interactive-elements) -->

### Usage Examples

#### Inside a Section
Expand Down
6 changes: 6 additions & 0 deletions packages/components/accordion/accordion.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@

<!-- example(accordion-content) -->

##### Интерактивные элементы

Кнопки, выпадающие меню и элементы формы можно размещать в шапке секции рядом с триггером, а также внутри области контента. Размещайте их **рядом** с триггером, но не внутри него: триггер имеет роль `button`, и вложение в него фокусируемых элементов нарушает доступность. Enter и Space раскрывают и сворачивают секцию только когда фокус находится на самом триггере.

<!-- example(accordion-interactive-elements) -->

### Примеры использования

#### Внутри секции
Expand Down
21 changes: 21 additions & 0 deletions packages/components/accordion/accordion.scss
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,27 @@
display: flex;

height: var(--kbq-accordion-size-item-header-height);
// The height above is fixed, so a trigger label long enough to wrap would paint outside the
// header and over the item's border. Clipping keeps it inside; the trigger is a flex container,
// so `text-overflow` cannot ellipsize its text — wrap the label in an element of your own if you
// need that.
overflow: hidden;

// Interactive controls live NEXT TO the trigger, never inside it: the trigger is `role="button"`
// and nesting focusable elements in it is an accessibility violation. The trigger's `flex: 1`
// keeps the rest of the header clickable, so the actions sit flush right; their padding matches
// the trigger's own right padding, which is `--kbq-size-s` in every variant.
& > .kbq-accordion-header__actions {
display: flex;
align-items: center;
gap: var(--kbq-accordion-size-item-header-actions-gap);

// Icon buttons have a fixed size and nothing to reflow, so the trigger — not the actions —
// is what gives way when the header runs out of room.
flex-shrink: 0;

padding-right: var(--kbq-accordion-size-item-header-actions-padding-right);
}
}

.kbq-accordion-content {
Expand Down
Loading