diff --git a/community/components/buttons/floating-button/floating-button.component.scss b/community/components/buttons/floating-button/floating-button.component.scss new file mode 100644 index 000000000..09eb8c548 --- /dev/null +++ b/community/components/buttons/floating-button/floating-button.component.scss @@ -0,0 +1,48 @@ +@use "@tedi-design-system/core/mixins"; +@use "@tedi-design-system/core/bootstrap-utility/breakpoints"; +@use "../../../../tedi/components/buttons/button/button.component.scss" as + buttonMixins; + +button.tedi-floating-button { + box-shadow: 0 4px 10px 0 var(--alpha-14); + + @include buttonMixins.button-main-styles(); + + &--vertical { + min-width: max-content; + white-space: nowrap; + border-radius: 0; + transform: rotate(-90deg); + transform-origin: center; + + @include mixins.responsive-styles( + border-top-right-radius, + button-radius-sm + ); + @include mixins.responsive-styles(border-top-left-radius, button-radius-sm); + } + + &.tedi-floating-button--primary { + @include buttonMixins.button-variant-color-vars( + "primary", + false, + "floating" + ); + } + + &.tedi-floating-button--secondary { + @include buttonMixins.button-variant-color-vars( + "secondary", + false, + "floating" + ); + } + + &--default { + @include buttonMixins.button-size("md"); + } + + &--large { + @include buttonMixins.button-size("lg"); + } +} diff --git a/community/components/buttons/floating-button/floating-button.component.ts b/community/components/buttons/floating-button/floating-button.component.ts new file mode 100644 index 000000000..802796dd5 --- /dev/null +++ b/community/components/buttons/floating-button/floating-button.component.ts @@ -0,0 +1,62 @@ +import { + Component, + computed, + input, + ViewEncapsulation, + OnInit, + inject, +} from "@angular/core"; +import { BaseButtonDirective } from "tedi/components"; + +export type FloatingButtonVariant = "primary" | "secondary"; + +export type FloatingButtonSize = "default" | "large"; +export type FloatingButtonAxis = "horizontal" | "vertical"; + +@Component({ + selector: "[tedi-floating-button]", + template: ``, + styleUrl: "./floating-button.component.scss", + encapsulation: ViewEncapsulation.None, + hostDirectives: [ + { + directive: BaseButtonDirective, + }, + ], + host: { + "[class]": "floatClasses()", + }, +}) +export class FloatingButtonComponent implements OnInit { + /** + * Specifies the color theme of the button. The color should meet accessibility standards for color contrast. + * @default primary + */ + variant = input("primary"); + /** + * Defines the size of the button. + * @default default + */ + size = input("default"); + /** + * Button axis + * @default horizontal + */ + axis = input("horizontal"); + + buttonDirective = inject(BaseButtonDirective); + + ngOnInit() { + this.buttonDirective.classNamePrefix.set("tedi-floating-button"); + } + + floatClasses = computed(() => { + const classes = [ + "tedi-floating-button", + `tedi-floating-button--${this.variant() ?? "primary"}`, + `tedi-floating-button--${this.size() ?? "default"}`, + `tedi-floating-button--${this.axis() ?? "horizontal"}`, + ]; + return classes.join(" "); + }); +} diff --git a/community/components/buttons/floating-button/floating-button.stories.ts b/community/components/buttons/floating-button/floating-button.stories.ts new file mode 100644 index 000000000..b93ee7539 --- /dev/null +++ b/community/components/buttons/floating-button/floating-button.stories.ts @@ -0,0 +1,150 @@ +import { + argsToTemplate, + Meta, + moduleMetadata, + StoryObj, +} from "@storybook/angular"; +import { FloatingButtonComponent } from "./floating-button.component"; +import { IconComponent } from "tedi/components"; + +const buttonSizeArray = ["default", "large"]; +const buttonStateArray = ["Default", "Hover", "Active", "Focus"]; + +interface StoryArgs { + textOffset: string; +} + +type StoryFloatingButtonArgs = FloatingButtonComponent & StoryArgs; + +/** + * Figma ↗
+ * Zeroheight ↗
+ **/ +const meta: Meta = { + title: "Community/Buttons/Floating Button", + component: FloatingButtonComponent, + + decorators: [ + moduleMetadata({ + imports: [FloatingButtonComponent, IconComponent], + }), + ], + args: { + variant: "primary", + axis: "horizontal", + textOffset: "30px", + size: "default", + }, + argTypes: { + variant: { + control: "select", + description: "Specifies the color theme of the button.", + options: ["primary", "secondary", "neutral", "success"], + }, + axis: { + control: "radio", + description: "Button axis, changes the orientation of the button.", + options: ["horizontal", "vertical"], + }, + size: { + control: "radio", + description: "Button size.", + options: ["default", "large"], + }, + // not meant to be user-editable or seen + textOffset: { + table: { + disable: true, + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + parameters: { + pseudo: { + hover: "#Hover", + active: "#Active", + focusVisible: "#Focus", + }, + offset: -30, + }, + render: ({ textOffset: _textOffset, ...args }) => ({ + props: { ...args, debug: () => console.log("floating button clicked!") }, + template: ` +
+ +
+ `, + }), +}; + +export const sizesVertical: Story = { + ...Default, + args: { + axis: "vertical", + }, + render: (args) => ({ + props: { ...args, buttonSizeArray }, + template: ` +
+ @for(size of buttonSizeArray; track size) { +
+
{{size}}
+ + + + + + + +
+ } +
`, + }), +}; + +export const statesVertical: Story = { + ...Default, + args: { + axis: "vertical", + }, + render: (args) => ({ + props: { ...args, buttonStateArray }, + template: ` +
+ @for(state of buttonStateArray; track state) { +
+
{{state}}
+ + + + + + + +
+ } +
`, + }), +}; + +export const sizesHorizontal: Story = { + ...sizesVertical, + args: { + axis: "horizontal", + textOffset: "0px", + }, +}; + +export const statesHorizontal: Story = { + ...statesVertical, + args: { + axis: "horizontal", + textOffset: "0px", + }, +}; diff --git a/community/components/buttons/floating-button/index.ts b/community/components/buttons/floating-button/index.ts new file mode 100644 index 000000000..6114a84bf --- /dev/null +++ b/community/components/buttons/floating-button/index.ts @@ -0,0 +1 @@ +export * from "./floating-button.component"; diff --git a/community/components/buttons/index.ts b/community/components/buttons/index.ts new file mode 100644 index 000000000..96e4859ad --- /dev/null +++ b/community/components/buttons/index.ts @@ -0,0 +1 @@ +export * from "./floating-button"; diff --git a/community/index.ts b/community/index.ts index 3c0b3f1ac..fa989c6d1 100644 --- a/community/index.ts +++ b/community/index.ts @@ -1,3 +1,4 @@ +export * from "./components/buttons"; export * from "./components/cards"; export * from "./components/form"; export * from "./components/navigation"; diff --git a/tedi/components/buttons/button/base-button.directive.ts b/tedi/components/buttons/button/base-button.directive.ts new file mode 100644 index 000000000..5dbfde55f --- /dev/null +++ b/tedi/components/buttons/button/base-button.directive.ts @@ -0,0 +1,66 @@ +import { + Directive, + AfterContentChecked, + signal, + inject, + ElementRef, + computed, +} from "@angular/core"; + +@Directive({ + host: { + "[class]": "classes()", + }, +}) +export class BaseButtonDirective implements AfterContentChecked { + /** + * CSS class name affix the directive should provide + */ + classNamePrefix = signal("tedi-button"); + + private host = inject(ElementRef); + iconOnly = signal(false); + iconFirst = signal(false); + iconLast = signal(false); + + ngAfterContentChecked(): void { + const hostElement = this.host.nativeElement as HTMLElement; + const nodes = Array.from(hostElement.childNodes).filter( + (node) => + node.nodeType === Node.ELEMENT_NODE || + (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) + ); + const nodeCount = nodes.length; + const iconIndexes = nodes + .map((node, index) => ({ node, index })) + .filter( + (x) => + x.node.nodeType === Node.ELEMENT_NODE && + x.node.nodeName === "TEDI-ICON" + ) + .map((x) => x.index); + + const iconCount = iconIndexes.length; + this.iconOnly.set(nodeCount === 1 && iconCount === 1); + this.iconFirst.set(iconIndexes.includes(0)); + this.iconLast.set(iconIndexes.includes(nodes.length - 1)); + } + + classes = computed(() => { + const classList = [this.classNamePrefix()]; + + if (this.iconOnly()) { + classList.push(`${this.classNamePrefix()}--icon-only`); + } + + if (!this.iconFirst()) { + classList.push(`${this.classNamePrefix()}--pl`); + } + + if (!this.iconLast()) { + classList.push(`${this.classNamePrefix()}--pr`); + } + + return classList.join(" "); + }); +} diff --git a/tedi/components/buttons/button/button.component.scss b/tedi/components/buttons/button/button.component.scss index 9bc60658e..17463dbe8 100644 --- a/tedi/components/buttons/button/button.component.scss +++ b/tedi/components/buttons/button/button.component.scss @@ -3,46 +3,53 @@ $neutral-variants: "neutral", "neutral-inverted", "danger-neutral"; -@mixin button-variant-color-vars($variant, $icon-only: false) { +@mixin button-variant-color-vars($variant, $icon-only: false, $affix: main) { & { @if $icon-only { - --_btn-bg: var(--button-main-#{$variant}-icon-only-background-default); + --_btn-bg: var( + --button-#{$affix}-#{$variant}-icon-only-background-default + ); } @else { - --_btn-bg: var(--button-main-#{$variant}-background-default); + --_btn-bg: var(--button-#{$affix}-#{$variant}-background-default); } - --_btn-border: var(--button-main-#{$variant}-border-default); - --_btn-text: var(--button-main-#{$variant}-text-default); + --_btn-border: var(--button-#{$affix}-#{$variant}-border-default); + --_btn-text: var(--button-#{$affix}-#{$variant}-text-default); --_btn-outline: var(--primary-500); - @include button-state-color-vars($variant, "hover", $icon-only); - @include button-state-color-vars($variant, "focus", $icon-only); - @include button-state-color-vars($variant, "active", $icon-only); + @include button-state-color-vars($variant, "hover", $icon-only, $affix); + @include button-state-color-vars($variant, "focus", $icon-only, $affix); + @include button-state-color-vars($variant, "active", $icon-only, $affix); @include button-disabled-color-vars($variant); } } -@mixin button-state-color-vars($variant, $state, $icon-only: false) { +@mixin button-state-color-vars( + $variant, + $state, + $icon-only: false, + $affix: main +) { & { @if $icon-only { --_btn-#{$state}-bg: var( - --button-main-#{$variant}-icon-only-background-#{$state}, - var(--button-main-#{$variant}-icon-only-background-default) + --button-#{$affix}-#{$variant}-icon-only-background-#{$state}, + var(--button-#{$affix}-#{$variant}-icon-only-background-default) ); } @else { --_btn-#{$state}-bg: var( - --button-main-#{$variant}-background-#{$state}, - var(--button-main-#{$variant}-background-default) + --button-#{$affix}-#{$variant}-background-#{$state}, + var(--button-#{$affix}-#{$variant}-background-default) ); } --_btn-#{$state}-border: var( - --button-main-#{$variant}-border-#{$state}, - var(--button-main-#{$variant}-border-default) + --button-#{$affix}-#{$variant}-border-#{$state}, + var(--button-#{$affix}-#{$variant}-border-default) ); --_btn-#{$state}-text: var( - --button-main-#{$variant}-text-#{$state}, - var(--button-main-#{$variant}-text-default) + --button-#{$affix}-#{$variant}-text-#{$state}, + var(--button-#{$affix}-#{$variant}-text-default) ); } } @@ -103,37 +110,106 @@ $neutral-variants: "neutral", "neutral-inverted", "danger-neutral"; } } -.tedi-button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--_btn-inner-spacing); - text-decoration: none; - background: var(--_btn-bg); - color: var(--_btn-text); - border: var(--borders-01) solid var(--_btn-border); - padding: var(--_btn-padding); - transition: 150ms ease; - transition-property: background-color, border-color; - cursor: pointer; +@mixin button-main-styles { + & { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--_btn-inner-spacing); + text-decoration: none; + background: var(--_btn-bg); + color: var(--_btn-text); + border: var(--borders-01) solid var(--_btn-border); + padding: var(--_btn-padding); + transition: 150ms ease; + transition-property: background-color, border-color; + cursor: pointer; - @include mixins.responsive-styles( - font-family, - family-primary, - $exclude: tablet - ); - @include mixins.responsive-styles(font-weight, body-regular-weight); - @include mixins.responsive-styles(line-height, body-bold-line-height); - @include mixins.responsive-styles(border-radius, button-radius-default); + @include mixins.responsive-styles( + font-family, + family-primary, + $exclude: tablet + ); + @include mixins.responsive-styles(font-weight, body-regular-weight); + @include mixins.responsive-styles(line-height, body-bold-line-height); + @include mixins.responsive-styles(border-radius, button-radius-default); + + &--icon-only { + @include mixins.responsive-styles(width, button-md-icon-size); + @include mixins.responsive-styles(height, button-md-icon-size); + @include mixins.responsive-styles(padding, button-md-icon-padding); + } + + &--pl { + &:not(.tedi-button--neutral):not(.tedi-button--danger-neutral):not( + .tedi-button--neutral-inverted + ) { + padding-left: calc( + var(--_btn-padding-x) + var(--_btn-inner-spacing) - 1px + ); + } + } + + &--pr { + &:not(.tedi-button--neutral):not(.tedi-button--danger-neutral):not( + .tedi-button--neutral-inverted + ) { + padding-right: calc( + var(--_btn-padding-x) + var(--_btn-inner-spacing) - 1px + ); + } + } + + &:hover { + background: var(--_btn-hover-bg); + border-color: var(--_btn-hover-border); + color: var(--_btn-hover-text); + } + + &:active { + background: var(--_btn-active-bg); + border-color: var(--_btn-active-border); + color: var(--_btn-active-text); + } + + &:focus-visible { + background: var(--_btn-focus-bg); + border-color: var(--_btn-focus-border); + color: var(--_btn-focus-text); + outline: 2px solid var(--_btn-outline); + outline-offset: 1px; + } + + &:disabled { + background: var(--_btn-disabled-bg); + border-color: var(--_btn-disabled-border); + color: var(--_btn-disabled-text); + cursor: not-allowed; + } + + tedi-icon { + color: inherit; + font-size: inherit; + } + } +} + +@mixin button-size($size) { + & { + @include button-size-vars(#{$size}); + @include mixins.responsive-styles(font-size, button-text-size-#{$size}); + } +} + +.tedi-button { + @include button-main-styles(); &--small { - @include button-size-vars("sm"); - @include mixins.responsive-styles(font-size, button-text-size-sm); + @include button-size("sm"); } &--default { - @include button-size-vars("md"); - @include mixins.responsive-styles(font-size, button-text-size-default); + @include button-size("md"); } &--primary { @@ -176,10 +252,6 @@ $neutral-variants: "neutral", "neutral-inverted", "danger-neutral"; } &--icon-only { - @include mixins.responsive-styles(width, button-md-icon-size); - @include mixins.responsive-styles(height, button-md-icon-size); - @include mixins.responsive-styles(padding, button-md-icon-padding); - &.tedi-button--neutral { @include button-variant-color-vars("neutral", true); } @@ -198,56 +270,4 @@ $neutral-variants: "neutral", "neutral-inverted", "danger-neutral"; @include mixins.responsive-styles(padding, button-sm-icon-padding); } } - - &--pl { - &:not(.tedi-button--neutral):not(.tedi-button--danger-neutral):not( - .tedi-button--neutral-inverted - ) { - padding-left: calc( - var(--_btn-padding-x) + var(--_btn-inner-spacing) - 1px - ); - } - } - - &--pr { - &:not(.tedi-button--neutral):not(.tedi-button--danger-neutral):not( - .tedi-button--neutral-inverted - ) { - padding-right: calc( - var(--_btn-padding-x) + var(--_btn-inner-spacing) - 1px - ); - } - } - - &:hover { - background: var(--_btn-hover-bg); - border-color: var(--_btn-hover-border); - color: var(--_btn-hover-text); - } - - &:active { - background: var(--_btn-active-bg); - border-color: var(--_btn-active-border); - color: var(--_btn-active-text); - } - - &:focus-visible { - background: var(--_btn-focus-bg); - border-color: var(--_btn-focus-border); - color: var(--_btn-focus-text); - outline: 2px solid var(--_btn-outline); - outline-offset: 1px; - } - - &:disabled { - background: var(--_btn-disabled-bg); - border-color: var(--_btn-disabled-border); - color: var(--_btn-disabled-text); - cursor: not-allowed; - } - - tedi-icon { - color: inherit; - font-size: inherit; - } } diff --git a/tedi/components/buttons/button/button.component.spec.ts b/tedi/components/buttons/button/button.component.spec.ts index 67225b3d6..46dceced1 100644 --- a/tedi/components/buttons/button/button.component.spec.ts +++ b/tedi/components/buttons/button/button.component.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { ButtonComponent, ButtonSize, ButtonVariant } from "./button.component"; import { IconComponent } from "../../base/icon/icon.component"; +import { ButtonComponent, ButtonSize, ButtonVariant } from "./button.component"; describe("ButtonComponent", () => { let fixture: ComponentFixture; diff --git a/tedi/components/buttons/button/button.component.ts b/tedi/components/buttons/button/button.component.ts index 3f5b716a4..91ff42e8c 100644 --- a/tedi/components/buttons/button/button.component.ts +++ b/tedi/components/buttons/button/button.component.ts @@ -1,13 +1,5 @@ -import { - Component, - computed, - ElementRef, - inject, - input, - signal, - ViewEncapsulation, - AfterContentChecked, -} from "@angular/core"; +import { Component, computed, input, ViewEncapsulation } from "@angular/core"; +import { BaseButtonDirective } from "./base-button.directive"; export type ButtonVariant = | "primary" @@ -28,11 +20,16 @@ export type ButtonSize = "default" | "small"; template: "", styleUrl: "./button.component.scss", encapsulation: ViewEncapsulation.None, + hostDirectives: [ + { + directive: BaseButtonDirective, + }, + ], host: { "[class]": "classes()", }, }) -export class ButtonComponent implements AfterContentChecked { +export class ButtonComponent { /** * Specifies the color theme of the button. The color should meet accessibility standards for color contrast. * @default primary @@ -44,45 +41,12 @@ export class ButtonComponent implements AfterContentChecked { */ size = input("default"); - private host = inject(ElementRef); - iconOnly = signal(false); - iconFirst = signal(false); - iconLast = signal(false); - - ngAfterContentChecked(): void { - const hostElement = this.host.nativeElement as HTMLElement; - const nodes = Array.from(hostElement.childNodes).filter(node => node.nodeType === Node.ELEMENT_NODE || (node.nodeType === Node.TEXT_NODE && node.textContent?.trim())); - const nodeCount = nodes.length; - const iconIndexes = nodes - .map((node, index) => ({ node, index })) - .filter(x => x.node.nodeType === Node.ELEMENT_NODE && x.node.nodeName === "TEDI-ICON") - .map(x => x.index); - - const iconCount = iconIndexes.length; - this.iconOnly.set(nodeCount === 1 && iconCount === 1); - this.iconFirst.set(iconIndexes.includes(0)); - this.iconLast.set(iconIndexes.includes(nodes.length - 1)); - } - classes = computed(() => { const classList = [ "tedi-button", `tedi-button--${this.variant()}`, `tedi-button--${this.size()}`, ]; - - if (this.iconOnly()) { - classList.push("tedi-button--icon-only"); - } - - if (!this.iconFirst()) { - classList.push("tedi-button--pl"); - } - - if (!this.iconLast()) { - classList.push("tedi-button--pr"); - } - return classList.join(" "); }); } diff --git a/tedi/components/buttons/button/button.stories.ts b/tedi/components/buttons/button/button.stories.ts index a1dfcb932..3d240dd41 100644 --- a/tedi/components/buttons/button/button.stories.ts +++ b/tedi/components/buttons/button/button.stories.ts @@ -10,6 +10,7 @@ import { TextColor, TextComponent } from "../../base/text/text.component"; import { RowComponent } from "../../helpers/grid/row/row.component"; import { ColComponent } from "../../helpers/grid/col/col.component"; import { IconComponent } from "../../base/icon/icon.component"; +import { BaseButtonDirective } from "./base-button.directive"; const PSEUDO_STATE = ["Default", "Hover", "Active", "Focus", "Disabled"]; @@ -78,7 +79,7 @@ export default { }, }, }, -} as Meta; +} as Meta; type ButtonType = ButtonComponent & { ngContent: string }; @@ -92,7 +93,8 @@ export const Default: StoryObj = { }), }; -type TemplateType = ButtonComponent & { titleColor?: TextColor }; +type TemplateType = ButtonType & + BaseButtonDirective & { titleColor?: TextColor }; const ButtonTemplate: StoryFn = ({ titleColor = "primary", @@ -147,7 +149,7 @@ const ButtonTemplate: StoryFn = ({ `, }); -type ButtonStory = StoryObj; +type ButtonStory = StoryObj; export const Primary: StoryObj = { parameters: { diff --git a/tedi/components/buttons/index.ts b/tedi/components/buttons/index.ts index 119c4d2ed..6b03de438 100644 --- a/tedi/components/buttons/index.ts +++ b/tedi/components/buttons/index.ts @@ -2,3 +2,4 @@ export * from "./button/button.component"; export * from "./info-button/info-button.component"; export * from "./collapse/collapse.component"; export * from "./closing-button/closing-button.component"; +export * from "./button/base-button.directive";