diff --git a/community/components/navigation/vertical-stepper/index.ts b/community/components/navigation/vertical-stepper/index.ts new file mode 100644 index 00000000..18dbe5e8 --- /dev/null +++ b/community/components/navigation/vertical-stepper/index.ts @@ -0,0 +1,2 @@ +export * from "./vertical-stepper.component"; +export * from "./vertical-stepper-item/vertical-stepper-item.component"; diff --git a/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.component.html b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.component.html new file mode 100644 index 00000000..28ce0f9b --- /dev/null +++ b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.component.html @@ -0,0 +1,79 @@ + + @if (subItem() || !compact()) { + @if (completed()) { + + } @else if (error()) { + + } + } + + +
+ @if (!selected() && compact() && !subItem()) { + @if (completed()) { + + } @else if (error()) { + + } + } +
+
+
+ @if (hasSubItems()) { + + } @else { + + @if (route()) { + {{ title() }} + } @else { + + } + + + } +
+
+ +
+@if (opened()) { + +} diff --git a/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.component.scss b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.component.scss new file mode 100644 index 00000000..2215c69c --- /dev/null +++ b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.component.scss @@ -0,0 +1,271 @@ +.tedi-vertical-stepper-item { + $host-class: &; + display: grid; + position: relative; + + --_step-indicator-size: 24px; + --_step-indicator-color: var(--general-text-secondary, #4b4e62); + --_step-indicator-border: var(--stepper-step-default-border, #9293a4); + --_step-indicator-border-hover: var( + --stepper-step-default-border-hover, + #005aa3 + ); + --_step-indicator-border-width: 1px; + --_step-indicator-background: var(--stepper-step-default-bg, #fff); + --_step-indicator-background-hover: var(--stepper-step-default-bg, #fff); + + --_step-padding: var(--stepper-item-vertical-lg-padding-top, 8px); + --_step-inner-spacing: var(--stepper-item-vertical-lg-inner-spacing, 8px); + --_step-line-color: var(--stepper-item-vertical-line, #9293a4); + + --_step-title-color: var(--stepper-item-vertical-text-default, #151926); + --_step-title-color-hover: var(--stepper-item-vertical-text-hover, #004277); + --_step-title-size: var(--body-regular-size, 16px); + --_step-title-weight: var(--body-regular-weight, 400); + --_step-title-line-height: var(--body-regular-line-height, 24px); + --_step-title-spacing: var(--layout-grid-gutters-08, 8px); + + grid-template-areas: + "indicator title" + ". description"; + grid-template-columns: auto 1fr; + grid-auto-columns: auto; + column-gap: var(--_step-inner-spacing); + z-index: 0; + + &__indicator { + grid-area: indicator; + display: flex; + align-items: center; + justify-content: center; + height: var(--_step-indicator-size); + width: var(--_step-indicator-size); + border: var(--_step-indicator-border) solid + var(--_step-indicator-border-width); + background-color: var(--_step-indicator-background); + color: var(--_step-indicator-color); + border-radius: 9999px; + font-size: var(--body-small-bold-size, 14px); + font-weight: var(--body-bold-weight, 700); + align-self: center; + justify-self: center; + + &::before { + content: counter(step-number); + } + } + + &:has(> &__title :is(button, a):hover) > &__indicator { + background-color: var(--_step-indicator-background-hover); + border-color: var(--_step-indicator-border-hover); + } + + &:not(&--sub-item) { + counter-increment: step-number; + } + + &__title { + grid-area: title; + padding: var(--_step-padding) 0; + + a, + button { + padding: 0; + background: none; + border: none; + text-decoration: none; + font-family: var(--family-default, Roboto); + font-size: var(--_step-title-size); + font-weight: var(--_step-title-weight); + line-height: var(--_step-title-line-height); + cursor: pointer; + color: var(--_step-title-color); + + &:hover { + color: var(--_step-title-color-hover); + &:not(#{$host-class}__toggle) { + text-decoration: underline; + } + } + } + } + + &__toggle { + display: flex; + width: 100%; + align-items: center; + + &:hover > span { + text-decoration: underline; + } + } + + &__toggle-icon { + margin-left: auto; + transition: transform 0.3s; + &--opened { + transform: rotateX(180deg); + } + } + + &__status-icon { + margin-inline: var(--_step-title-spacing); + } + + &__description { + grid-area: description; + + &:empty { + display: none; + } + } + + &__line { + &::before, + &::after { + content: ""; + grid-row: 1 / span 1; + grid-column: 1 / span 1; + left: 50%; + transform: translateX(-50%); + position: absolute; + width: 1px; + background-color: var(--_step-line-color); + top: 0; + bottom: 0; + z-index: -1; + } + + &::before { + grid-row: 1 / span 1; + } + + &::after { + grid-row: 2 / span 1; + } + } + + &:not(&--sub-item):first-child > &__line { + &::before { + top: 50%; + } + } + + &:not(&--sub-item):last-child { + &:not(:has(#{$host-class}--sub-item)) > #{$host-class}__line::before { + bottom: 50%; + } + & > #{$host-class}__line::after { + content: none; + } + + #{$host-class}--sub-item:last-child #{$host-class}__line { + &::before { + bottom: 50%; + } + &::after { + content: none; + } + } + } + + &--compact { + --_step-indicator-size: var(--stepper-item-vertical-step-size-md, 16px); + --_step-padding: var(--stepper-item-vertical-compact-padding-top, 3px); + --_step-inner-spacing: var( + --stepper-item-vertical-compact-inner-spacing, + 6px + ); + --_step-title-spacing: var(--layout-grid-gutters-04, 4px); + #{$host-class}__indicator::before { + content: none; + } + + &#{$host-class}--enumerated:not(#{$host-class}--sub-item) + > #{$host-class}__title + :is(a, button)::before { + content: counter(step-number) "."; + display: inline-block; + } + } + + &--sub-item { + grid-column: 1 / span 2; + grid-template-columns: subgrid; + --_step-indicator-size: var(--stepper-item-vertical-step-size-sm, 9px); + --_step-padding: var(--stepper-item-vertical-padding-y-sm, 2px); + + #{$host-class}__indicator::before { + content: none; + } + } + + &--completed { + --_step-indicator-background: var(--stepper-step-completed-bg, #266b42); + --_step-indicator-border: var(--stepper-step-completed-bg, #266b42); + --_step-indicator-color: var(--general-text-white, #fff); + --_step-indicator-border-hover: var( + --stepper-step-completed-bg-hover, + #1d5032 + ); + --_step-indicator-background-hover: var( + --stepper-step-completed-bg-hover, + #1d5032 + ); + } + + &--error { + --_step-indicator-background: var(--stepper-step-danger-bg, #ac3232); + --_step-indicator-border: var(--stepper-step-danger-bg, #ac3232); + --_step-indicator-color: var(--general-text-white, #fff); + --_step-indicator-border-hover: var( + --stepper-step-danger-bg-hover, + #812525 + ); + --_step-indicator-background-hover: var( + --stepper-step-danger-bg-hover, + #812525 + ); + } + + &--selected { + --_step-indicator-background: var(--stepper-step-selected-bg, #fff); + --_step-indicator-color: var( + --stepper-item-vertical-text-selected, + #004277 + ); + --_step-indicator-border: var(--stepper-step-selected-border, #004277); + --_step-indicator-border-width: 2px; + --_step-indicator-border-hover: var( + --stepper-step-selected-border-hover, + #003662 + ); + --_step-title-color: var(--stepper-item-vertical-text-selected, #004277); + --_step-title-weight: var(--body-bold-weight, 700); + &#{$host-class}--compact:not(#{$host-class}--sub-item) { + --_step-indicator-border-width: 4px; + } + } + + &--disabled { + --_step-indicator-background: var(--stepper-step-disabled-bg, #d2d3d8); + --_step-indicator-color: var( + --stepper-item-vertical-text-disabled, + #9293a4 + ); + --_step-indicator-border: var(--stepper-step-disabled-border, #9293a4); + --_step-title-color: var(--stepper-item-vertical-text-disabled, #9293a4); + #{$host-class}__title { + pointer-events: none; + } + } + + &--informative { + --_step-indicator-background: var(--stepper-step-disabled-bg, #d2d3d8); + --_step-indicator-border: var(--stepper-step-disabled-border, #9293a4); + --_step-title-color: var(--general-text-tertiary, #5d6071); + #{$host-class}__title { + pointer-events: none; + } + } +} diff --git a/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.component.ts b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.component.ts new file mode 100644 index 00000000..b8d1af51 --- /dev/null +++ b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.component.ts @@ -0,0 +1,88 @@ +import { + booleanAttribute, + ChangeDetectionStrategy, + Component, + computed, + contentChildren, + effect, + inject, + input, + model, + output, + signal, + ViewEncapsulation, +} from "@angular/core"; +import { VerticalStepperComponent } from "../vertical-stepper.component"; +import { RouterLink, RouterLinkActive } from "@angular/router"; +import { NgTemplateOutlet } from "@angular/common"; +import { + IconComponent, + TediTranslationPipe, +} from "@tedi-design-system/angular/tedi"; + +@Component({ + selector: "tedi-vertical-stepper-item", + imports: [ + IconComponent, + RouterLink, + RouterLinkActive, + NgTemplateOutlet, + TediTranslationPipe, + ], + templateUrl: "./vertical-stepper-item.component.html", + styleUrl: "./vertical-stepper-item.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + host: { + "[class.tedi-vertical-stepper-item]": "true", + "[class.tedi-vertical-stepper-item--completed]": "completed()", + "[class.tedi-vertical-stepper-item--error]": "error()", + "[class.tedi-vertical-stepper-item--selected]": "selected()", + "[class.tedi-vertical-stepper-item--disabled]": "disabled()", + "[class.tedi-vertical-stepper-item--informative]": "informative()", + "[class.tedi-vertical-stepper-item--sub-item]": "subItem()", + "[class.tedi-vertical-stepper-item--compact]": "compact()", + "[class.tedi-vertical-stepper-item--enumerated]": "enumerated()", + "[attr.role]": "'listitem'", + }, +}) +export class VerticalStepperItemComponent { + completed = input(false, { transform: booleanAttribute }); + error = input(false, { transform: booleanAttribute }); + selected = input(false, { transform: booleanAttribute }); + disabled = input(false, { transform: booleanAttribute }); + informative = input(false, { transform: booleanAttribute }); + title = input.required(); + route = input(undefined); + opened = model(false); // for items with children + + itemSelect = output(); + + private stepperContext = inject(VerticalStepperComponent, { optional: true }); + subItem = signal(false); + subItems = contentChildren(VerticalStepperItemComponent); + compact = computed(() => this.stepperContext?.compact()); + enumerated = computed(() => this.stepperContext?.enumerated()); + hasSubItems = computed(() => !!this.subItems().length); + + onSubItemSelect = effect(() => { + const subItemSelected = this.subItems().some((item) => item.selected()); + if (subItemSelected) { + this.opened.set(true); + } + }); + + onSubItemChanges = effect(() => { + this.subItems().forEach((item) => item.subItem.set(true)); + }); + + toggleOpen() { + this.opened.update((previouslyOpened) => !previouslyOpened); + } + + routerLinkActiveChange(isActive: boolean) { + if (isActive) { + this.itemSelect.emit(); + } + } +} diff --git a/community/components/navigation/vertical-stepper/vertical-stepper.component.html b/community/components/navigation/vertical-stepper/vertical-stepper.component.html new file mode 100644 index 00000000..8c590a16 --- /dev/null +++ b/community/components/navigation/vertical-stepper/vertical-stepper.component.html @@ -0,0 +1,5 @@ + diff --git a/community/components/navigation/vertical-stepper/vertical-stepper.component.scss b/community/components/navigation/vertical-stepper/vertical-stepper.component.scss new file mode 100644 index 00000000..d757cebf --- /dev/null +++ b/community/components/navigation/vertical-stepper/vertical-stepper.component.scss @@ -0,0 +1,3 @@ +.tedi-vertical-stepper { + counter-reset: step-number; +} diff --git a/community/components/navigation/vertical-stepper/vertical-stepper.component.ts b/community/components/navigation/vertical-stepper/vertical-stepper.component.ts new file mode 100644 index 00000000..a3dd76b3 --- /dev/null +++ b/community/components/navigation/vertical-stepper/vertical-stepper.component.ts @@ -0,0 +1,25 @@ +import { + booleanAttribute, + ChangeDetectionStrategy, + Component, + input, + ViewEncapsulation, +} from "@angular/core"; + +@Component({ + selector: "tedi-vertical-stepper", + imports: [], + templateUrl: "./vertical-stepper.component.html", + styleUrl: "./vertical-stepper.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + host: { + "[class.tedi-vertical-stepper]": "true", + "[class.tedi-vertical-stepper--compact]": "compact()", + }, +}) +export class VerticalStepperComponent { + ariaLabel = input(); + compact = input(false, { transform: booleanAttribute }); + enumerated = input(false, { transform: booleanAttribute }); +} diff --git a/community/components/navigation/vertical-stepper/vertical-stepper.stories.ts b/community/components/navigation/vertical-stepper/vertical-stepper.stories.ts new file mode 100644 index 00000000..e2da25b1 --- /dev/null +++ b/community/components/navigation/vertical-stepper/vertical-stepper.stories.ts @@ -0,0 +1,346 @@ +import { + argsToTemplate, + Meta, + moduleMetadata, + StoryObj, +} from "@storybook/angular"; +import { VerticalStepperComponent } from "./vertical-stepper.component"; +import { VerticalStepperItemComponent } from "./vertical-stepper-item/vertical-stepper-item.component"; +import { StatusBadgeComponent } from "../../tags/status-badge/status-badge.component"; + +/** + * The `vertical-stepper` component is stepper where steps are displayed in vertical sequence. + * + * Vertical-stepper component consists of individual `vertical-stepper-item` components. Steps have title and can be used as routes or buttons for non-routed navigation. + * + * Step title must be provided as input. Title template can be also provided as element with `item-title` attribute for cases with custom routing logic etc. + * + * Steps can also have description/action. They can be provided as element with `item-description` attribute. + * + * Steps can also have sub steps. They can be provided as nested `vertical-stepper-item` components. + */ + +export default { + title: "Community/Navigation/VerticalStepper", + component: VerticalStepperComponent, + decorators: [ + moduleMetadata({ + imports: [ + VerticalStepperComponent, + VerticalStepperItemComponent, + StatusBadgeComponent, + ], + }), + ], + argTypes: { + compact: { + description: "Whether it's the compact variant", + control: "boolean", + table: { + category: "vertical-stepper", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + enumerated: { + description: + "Used for compact variant, displays step number infront of the step title", + control: "boolean", + table: { + category: "vertical-stepper", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + ariaLabel: { + description: "Aria label for stepper", + control: "text", + table: { + category: "vertical-stepper", + type: { summary: "string" }, + }, + }, + itemTitle: { + name: "title", + description: + "Item title. Title can also be provided by element with `item-title` attribute. Input is required for mobile view", + control: "text", + table: { + category: "vertical-stepper-item", + type: { summary: "string" }, + }, + }, + itemCompleted: { + name: "completed", + description: "Is vertical stepper item completed", + control: "boolean", + table: { + category: "vertical-stepper-item", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + itemError: { + name: "error", + description: "Does vertical stepper item have error", + control: "boolean", + table: { + category: "vertical-stepper-item", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + itemSelected: { + name: "selected", + description: "Is vertical stepper item selected", + control: "boolean", + table: { + category: "vertical-stepper-item", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + itemDisabled: { + name: "disabled", + description: "Is vertical stepper item disabled", + control: "boolean", + table: { + category: "vertical-stepper-item", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + itemInformative: { + name: "informative", + description: + "Is vertical stepper item informative item. For sub items only", + control: "boolean", + table: { + category: "vertical-stepper-item", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + itemOpened: { + name: "opened", + description: + "Is vertical stepper item opened. For parent items with sub items only", + control: "boolean", + table: { + category: "vertical-stepper-item", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + itemRoute: { + name: "route", + description: + "Router link for item. If provided, leaf items title will be an anchor element, else button", + control: "text", + table: { + category: "vertical-stepper-item", + type: { summary: "RouterLink.routerLink: string | any[] | UrlTree" }, + }, + }, + itemSelect: { + description: "Event for when item is selected", + table: { + category: "vertical-stepper-item", + type: { summary: "output" }, + }, + }, + }, +} as Meta; + +export const Default: StoryObj = { + args: {}, + render: (args) => ({ + props: args, + template: ` + + + Description + + + + + + + + + + + + + + + + + `, + }), +}; + +export const Compact: StoryObj = { + args: { + compact: true, + }, + render: (args) => ({ + props: args, + template: ` + + + Description + + + + + + + + + + + + + + + + + `, + }), +}; + +export const EnumeratedCompact: StoryObj = { + args: { + compact: true, + enumerated: true, + }, + render: (args) => ({ + props: args, + template: ` + + + Description + + + + + + + + + + + + + + + + + `, + }), +}; + +export const WithRouterlinks: StoryObj = { + args: {}, + parameters: { + docs: { + description: { + story: + "For cases when steps are on multiple routes. Item emits `itemSelect` event when its routerLink becomes active", + }, + }, + }, + render: (args) => ({ + props: args, + template: ` + + + + + + + + + `, + }), +}; + +export const ProjectedTitleTemplates: StoryObj = { + args: {}, + parameters: { + docs: { + description: { + story: + "For cases when step titles need custom templates or advanced routing logic. For example fragmented navigation. `button` and `a` elements inherit styles", + }, + }, + }, + render: (args) => ({ + props: args, + template: ` + + + Link 1 + + + Link 2 + + + Link 3 + + + Link 4 + + + Link 5 + + + `, + }), +}; diff --git a/tedi/services/translation/translations.ts b/tedi/services/translation/translations.ts index fd9800fa..1a01fe10 100644 --- a/tedi/services/translation/translations.ts +++ b/tedi/services/translation/translations.ts @@ -936,6 +936,22 @@ export const translationsMap = { en: "Next years", ru: "Следующие годы", }, + "vertical-stepper.completed": { + description: + "Label for screen-reader that this step is completed (visually hidden)", + components: ["VerticalStepper"], + et: "Lõpetatud", + en: "Completed", + ru: "Завершено", + }, + "vertical-stepper.error": { + description: + "Label for screen-reader that this step has error (visually hidden)", + components: ["VerticalStepper"], + et: "Puudulik", + en: "Error", + ru: "Oшибка", + }, }; export type TediTranslationsMap = {