Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<tedi-card>
<tedi-card-content class="tedi-tab-card__content">
<h3>{{ title() }}</h3>
<div tedi-button variant="neutral">
<tedi-icon name="arrow_forward" class="tedi-tab-card__action-icon" [size]="18"/>
</div>
</tedi-card-content>
</tedi-card>
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
.tedi-tab-card {
width: 100%;

&__content.tedi-card-content {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--dimensions-13);
color: var(--tab-item-active-text);
width: 100%;
}

&__action-icon {
cursor: pointer;
}

&--disabled {
pointer-events: none;

.tedi-card-content {
color: var(--general-text-disabled);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {booleanAttribute, ChangeDetectionStrategy, Component, input, model, ViewEncapsulation} from '@angular/core';
import {ButtonComponent, IconComponent} from "@tedi-design-system/angular/tedi";
import {CardComponent, CardContentComponent} from "@tedi-design-system/angular/community";

@Component({
selector: '[tedi-tab-card]',
imports: [
ButtonComponent,
CardComponent,
CardContentComponent,
IconComponent
],
templateUrl: './tab-card.component.html',
styleUrl: './tab-card.component.scss',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
"[class.tedi-tab-card]": "true",
"[class.tedi-tab-card--disabled]": "disabledInput()",
"[attr.role]": "'tab'",
"[attr.aria-selected]": "selected()",
"[attr.aria-disabled]": "disabledInput()",
"(click)": "selectTab()",
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +6 to +24
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Keyboard activation and tab-pattern semantics are incomplete.

With selector: '[tedi-tab-card]' and role="tab" on the host, activation relies on whatever element the attribute is applied to. If the selector is used on a non-focusable element (e.g., <div tedi-tab-card>), users cannot select tabs via Enter/Space or arrow keys, and the element won't be in the tab order. Additionally, per the ARIA tab pattern the host should expose aria-controls pointing to the active tabpanel and manage roving tabindex.

Suggestions:

  • Add (keydown.enter) / (keydown.space) host listeners that call selectTab() (with $event.preventDefault() for Space).
  • Bind [attr.tabindex] (0 for selected/default, -1 otherwise) so arrow-key navigation can be layered on later.
  • Consider binding [attr.aria-controls] to the id of the rendered tabpanel in TabsVerticalComponent.

})
export class TabCardComponent {
readonly tabId = input.required<string>();
readonly title = input.required<string>();
readonly selected = model(false);

readonly disabledInput = input(false, {
transform: booleanAttribute,
// eslint-disable-next-line @angular-eslint/no-input-rename
alias: "disabled",
});

selectTab() {
if (this.disabledInput()) {
return;
}

this.selected.set(true);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +29 to +43
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -e ts 'tabs-vertical\.component\.ts$' | xargs rg -nC3 "selected|activeTab|contentChildren"

Repository: TEDI-Design-System/angular

Length of output: 1028


🏁 Script executed:

#!/bin/bash
# Find the complete TabsVerticalComponent to see if there are effects or outputs managing selection
fd -e ts 'tabs-vertical\.component\.ts$' -exec cat -n {} \;

Repository: TEDI-Design-System/angular

Length of output: 300


🏁 Script executed:

#!/bin/bash
# Search for calls to unselectAllTabs or any selection-related logic
rg -r 'unselectAllTabs|selectTab' --type ts -B2 -A2

Repository: TEDI-Design-System/angular

Length of output: 466


🏁 Script executed:

#!/bin/bash
# Check if there are any stories or examples showing the tab usage pattern
fd -e ts -e html 'tab.*story\|tab.*spec' | head -20

Repository: TEDI-Design-System/angular

Length of output: 52


🏁 Script executed:

#!/bin/bash
# Find and display the complete TabsVerticalComponent
fd -e ts 'tabs-vertical\.component\.ts$' -exec cat {} \;

Repository: TEDI-Design-System/angular

Length of output: 257


🏁 Script executed:

#!/bin/bash
# Search for calls to unselectAllTabs or selectTab with context
rg 'unselectAllTabs|selectTab' -B2 -A2 --type ts

Repository: TEDI-Design-System/angular

Length of output: 2297


🏁 Script executed:

#!/bin/bash
# Find tab-related stories and specs
fd '(tab|tabs).*\.(spec|stories)\.ts$'

Repository: TEDI-Design-System/angular

Length of output: 252


🏁 Script executed:

#!/bin/bash
# Read the complete TabsVerticalComponent file
cat community/components/navigation/tabs/tabs-vertical/tabs-vertical.component.ts

Repository: TEDI-Design-System/angular

Length of output: 1620


🏁 Script executed:

#!/bin/bash
# Read the tab-card component to see the full implementation
cat community/components/navigation/tabs/tab-card/tab-card.component.ts

Repository: TEDI-Design-System/angular

Length of output: 1374


🏁 Script executed:

#!/bin/bash
# Check the stories file for usage patterns
cat community/components/navigation/tabs/tabs.stories.ts

Repository: TEDI-Design-System/angular

Length of output: 4392


Multiple tabs can be selected = true simultaneously, breaking the tab pattern.

selectTab() only sets selected to true and never deselects siblings. Clicking tab A then tab B leaves both with selected=true (and aria-selected="true"). While TabsVerticalComponent has an unselectAllTabs() method, it is never called—there's no output event from TabCardComponent to trigger parent deselection, and no effect managing mutual exclusion.

Add an output event from TabCardComponent.selectTab() and have the parent respond by calling unselectAllTabs() and setting the clicked tab as selected, or use an effect to automatically manage deselection when a tab is selected.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@community/components/navigation/tabs/tab-card/tab-card.component.ts` around
lines 29 - 43, The selectTab() method on TabCardComponent currently only sets
this.selected to true, allowing multiple tabs to be selected; update
TabCardComponent to emit an output event when a tab is clicked (e.g., add an
EventEmitter like select = new EventEmitter<TabCardComponent>() and fire it from
selectTab() after the disabledInput() check) and have the parent
TabsVerticalComponent listen for that event and call its existing
unselectAllTabs() then set the clicked tab's selected model to true;
alternatively implement an effect in TabCardComponent that notifies the parent
(via the new output) so the parent can enforce mutual exclusion using
TabsVerticalComponent.unselectAllTabs() and then mark the emitting
TabCardComponent.selected.set(true).

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<ng-template #tabsList>
<ng-content select="[tedi-tab-card]"/>
</ng-template>

@if (activeTabContent(); as activeContent) {
<button tedi-button type="button" variant="neutral" class="tedi-tabs-vertical__back-link" (click)="unselectAllTabs()">
<tedi-icon name="arrow_back" [size]="18"/>
<span>{{ "back" | tediTranslate }}</span>
</button>

<h3 tedi-text>{{ activeTabTitle() }}</h3>

<tedi-card class="tedi-tabs-vertical__card">
<tedi-card-content>
<ng-container *ngTemplateOutlet="activeContent"/>
</tedi-card-content>
</tedi-card>
} @else {
<tedi-accordion class="tedi-tabs-vertical__accordion" role="tablist">
<ng-container *ngTemplateOutlet="tabsList"/>
</tedi-accordion>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
.tedi-tabs-vertical {
display: flex;
flex-direction: column;
align-items: start;
gap: var(--dimensions-10);

&__accordion,
&__card {
width: 100%;
}

&__back-link.tedi-button {
display: flex;
align-items: center;
gap: var(--dimensions-03);
cursor: pointer;
padding: var(--dimensions-02) var(--dimensions-03);
}

&__back-link > * {
color: var(--button-main-neutral-text-active);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {ChangeDetectionStrategy, Component, computed, contentChildren, ViewEncapsulation} from '@angular/core';
import {CardComponent, CardContentComponent, TabContentComponent} from "@tedi-design-system/angular/community";
import {TabCardComponent} from "../tab-card/tab-card.component";
import {
AccordionComponent,
ButtonComponent,
IconComponent,
TediTranslationPipe,
TextComponent
} from "@tedi-design-system/angular/tedi";
import {NgTemplateOutlet} from "@angular/common";

@Component({
selector: 'tedi-tabs-vertical',
imports: [
AccordionComponent,
ButtonComponent,
CardComponent,
CardContentComponent,
IconComponent,
NgTemplateOutlet,
TediTranslationPipe,
TextComponent
],
templateUrl: './tabs-vertical.component.html',
styleUrl: './tabs-vertical.component.scss',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
"[class.tedi-tabs-vertical]": "true",
},
})
export class TabsVerticalComponent {
private readonly tabs = contentChildren(TabCardComponent);
private readonly tabContents = contentChildren(TabContentComponent);

activeTabId = computed(() =>
this.tabs().find((tab) => tab.selected())?.tabId()
);

activeTabTitle = computed(() =>
this.tabs().find((tab) => tab.selected())?.title()
);

activeTabContent = computed(() =>
this.tabContents().find((content) => content.tabId() === this.activeTabId())?.content()
);

unselectAllTabs() {
this.tabs().forEach(tab => tab.selected.set(false));
}
}
86 changes: 69 additions & 17 deletions community/components/navigation/tabs/tabs.stories.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Meta, moduleMetadata, StoryFn, StoryObj } from "@storybook/angular";
import {Meta, moduleMetadata, StoryFn, StoryObj} from "@storybook/angular";

import { CommonModule } from "@angular/common";
import { TabsComponent } from "./tabs.component";
import { TabComponent } from "./tab/tab.component";
import { TabContentComponent } from "./tab-content/tab-content.component";
import {CommonModule} from "@angular/common";
import {TabsComponent} from "./tabs.component";
import {TabComponent} from "./tab/tab.component";
import {TabContentComponent} from "./tab-content/tab-content.component";
import {TabCardComponent} from "./tab-card/tab-card.component";
import {TabsVerticalComponent} from "./tabs-vertical/tabs-vertical.component";

/**
* <p>Tabs allow to group content into separate chunks to be displayed one at the time.</p>
Expand All @@ -17,47 +19,72 @@ export default {
decorators: [
moduleMetadata({
declarations: [],
imports: [CommonModule, TabsComponent, TabComponent, TabContentComponent],
imports: [CommonModule, TabsComponent, TabComponent, TabContentComponent, TabCardComponent, TabsVerticalComponent],
}),
],
argTypes: {
tabId: {
description: "Tab unique id",
table: {
category: "tab",
type: { summary: "string" },
type: {summary: "string"},
},
},
selected: {
description:
"Whether tab is initially selected. Should be used only for non routed tabs",
table: {
category: "tab",
type: { summary: "boolean" },
defaultValue: { summary: "false" },
type: {summary: "boolean"},
defaultValue: {summary: "false"},
},
},
disabled: {
description: "Whether tab is disabled",
table: {
category: "tab",
type: { summary: "boolean" },
defaultValue: { summary: "false" },
type: {summary: "boolean"},
defaultValue: {summary: "false"},
},
},
contentTabId: {
name: "tabId",
description: "Id that matches `tabId` given to the `tedi-tab` component",
table: {
category: "tab-content",
type: { summary: "string" },
type: {summary: "string"},
},
},
tabCardId: {
name: "tabId",
description: "Tab unique id",
table: {
category: "tab-card",
type: {summary: "string"},
},
},
tabCardTitle: {
name: "title",
description: "Tab title",
table: {
category: "tab-card",
type: {summary: "string"},
},
},
tabCardDisabled: {
name: "disabled",
description: "Whether tab is disabled",
table: {
category: "tab-card",
type: {summary: "boolean"},
defaultValue: {summary: "false"},
},
},
},
} as Meta<TabComponent>;

const TabsTemplate: StoryFn<TabComponent> = ({ ...args }) => ({
props: { ...args },
const TabsTemplate: StoryFn<TabComponent> = ({...args}) => ({
props: {...args},
template: `
<tedi-tabs>
<button tedi-tab [selected]="true" tabId="tab-1">Tab 1</button>
Expand All @@ -76,19 +103,40 @@ const TabsTemplate: StoryFn<TabComponent> = ({ ...args }) => ({
`,
});

const RoutedTabTemplate: StoryFn<TabComponent> = ({ ...args }) => ({
props: { ...args },
const RoutedTabTemplate: StoryFn<TabComponent> = ({...args}) => ({
props: {...args},
template: `
<tedi-tabs>
<a tedi-tab routerLink="1" tabId="tab-1">Tab 1</a>
<a tedi-tab routerLink="2" tabId="tab-2">Tab 2</a>
<a tedi-tab routerLink="3">Tab 3</a>

router-outlet goes here
</tedi-tabs>
`,
});

const VerticalTabTemplate: StoryFn<TabComponent> = ({...args}) => ({
props: {...args},
template: `
<tedi-tabs-vertical>
<div tedi-tab-card tabId="tab-1" title="Tab 1"></div>
<div tedi-tab-card tabId="tab-2" title="Tab 2"></div>
<div tedi-tab-card tabId="tab-3" title="Tab 3 (disabled)" [disabled]="true"></div>

<tedi-tab-content tabId="tab-1">
Tab 1 content
</tedi-tab-content>
<tedi-tab-content tabId="tab-2">
Tab 2 content
</tedi-tab-content>
<tedi-tab-content tabId="tab-3">
Tab 3 content
</tedi-tab-content>
</tedi-tabs-vertical>
`,
});

type TableStylesStory = StoryObj<TabComponent>;

export const Default: TableStylesStory = {
Expand All @@ -98,3 +146,7 @@ export const Default: TableStylesStory = {
export const RoutedTabs: TableStylesStory = {
render: RoutedTabTemplate,
};

export const VerticalTabs: TableStylesStory = {
render: VerticalTabTemplate,
};
6 changes: 6 additions & 0 deletions tedi/services/translation/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ export const translationsMap = {
en: "Breadcrumbs",
ru: "Навигационная цепочка",
},
"back": {
components: ["Tabs"],
et: "Tagasi",
en: "Back",
ru: "Назад",
},
more: {
components: ["Tabs"],
et: "Veel",
Expand Down
Loading