From 1aaef3676342968f301b1aa15d160b228e7b095e Mon Sep 17 00:00:00 2001 From: skyai_dev Date: Wed, 3 Sep 2025 14:41:16 +1000 Subject: [PATCH] Add split button component with comprehensive functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create new SplitButtonComponent with primary action and dropdown menu - Support for three variants: filled, tonal, outlined - Three size options: small, medium, large - Icon support with configurable positioning - Configurable dropdown menu items with icons and labels - Full accessibility support with ARIA attributes and keyboard navigation - Interactive states: hover, active, disabled, loading - Responsive design using semantic design tokens - Complete demo application with multiple examples - Integration with dashboard navigation menu 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../src/app/demos/demos.routes.ts | 34 +- .../split-button-demo.component.scss | 63 +++ .../split-button-demo.component.ts | 211 ++++++++++ .../features/dashboard/dashboard.component.ts | 16 +- .../src/lib/components/buttons/index.ts | 2 + .../components/buttons/split-button/index.ts | 1 + .../split-button/split-button.component.scss | 393 ++++++++++++++++++ .../split-button/split-button.component.ts | 173 ++++++++ 8 files changed, 888 insertions(+), 5 deletions(-) create mode 100644 projects/demo-ui-essentials/src/app/demos/split-button-demo/split-button-demo.component.scss create mode 100644 projects/demo-ui-essentials/src/app/demos/split-button-demo/split-button-demo.component.ts create mode 100644 projects/ui-essentials/src/lib/components/buttons/split-button/index.ts create mode 100644 projects/ui-essentials/src/lib/components/buttons/split-button/split-button.component.scss create mode 100644 projects/ui-essentials/src/lib/components/buttons/split-button/split-button.component.ts diff --git a/projects/demo-ui-essentials/src/app/demos/demos.routes.ts b/projects/demo-ui-essentials/src/app/demos/demos.routes.ts index a03f87d..0261efa 100644 --- a/projects/demo-ui-essentials/src/app/demos/demos.routes.ts +++ b/projects/demo-ui-essentials/src/app/demos/demos.routes.ts @@ -18,6 +18,7 @@ import { ProgressDemoComponent } from './progress-demo/progress-demo.component'; import { CardDemoComponent } from './card-demo/card-demo.component'; import { ChipDemoComponent } from './chip-demo/chip-demo.component'; import { AppbarDemoComponent } from './appbar-demo/appbar-demo.component'; +import { BottomNavigationDemoComponent } from './bottom-navigation-demo/bottom-navigation-demo.component'; import { FontAwesomeDemoComponent } from './fontawesome-demo/fontawesome-demo.component'; import { ImageContainerDemoComponent } from './image-container-demo/image-container-demo.component'; import { CarouselDemoComponent } from './carousel-demo/carousel-demo.component'; @@ -46,9 +47,14 @@ import { TooltipDemoComponent } from './tooltip-demo/tooltip-demo.component'; import { AccordionDemoComponent } from './accordion-demo/accordion-demo.component'; import { PopoverDemoComponent } from './popover-demo/popover-demo.component'; import { AlertDemoComponent } from './alert-demo/alert-demo.component'; +import { SnackbarDemoComponent } from './snackbar-demo/snackbar-demo.component'; import { ToastDemoComponent } from './toast-demo/toast-demo.component'; import { TreeViewDemoComponent } from './tree-view-demo/tree-view-demo.component'; import { TimelineDemoComponent } from './timeline-demo/timeline-demo.component'; +import { StepperDemoComponent } from './stepper-demo/stepper-demo.component'; +import { FabMenuDemoComponent } from './fab-menu-demo/fab-menu-demo.component'; +import { EnhancedTableDemoComponent } from './enhanced-table-demo/enhanced-table-demo.component'; +import { SplitButtonDemoComponent } from './split-button-demo/split-button-demo.component'; @Component({ @@ -65,6 +71,10 @@ import { TimelineDemoComponent } from './timeline-demo/timeline-demo.component'; } + @case ("bottom-navigation") { + + } + @case ("avatar") { } @@ -231,6 +241,10 @@ import { TimelineDemoComponent } from './timeline-demo/timeline-demo.component'; } + @case ("snackbar") { + + } + @case ("toast") { } @@ -243,6 +257,22 @@ import { TimelineDemoComponent } from './timeline-demo/timeline-demo.component'; } + @case ("stepper") { + + } + + @case ("fab-menu") { + + } + + @case ("enhanced-table") { + + } + + @case ("split-button") { + + } + } `, @@ -251,14 +281,14 @@ import { TimelineDemoComponent } from './timeline-demo/timeline-demo.component'; MenuDemoComponent, InputDemoComponent, LayoutDemoComponent, RadioDemoComponent, CheckboxDemoComponent, SearchDemoComponent, SwitchDemoComponent, ProgressDemoComponent, - AppbarDemoComponent, FontAwesomeDemoComponent, ImageContainerDemoComponent, + AppbarDemoComponent, BottomNavigationDemoComponent, FontAwesomeDemoComponent, ImageContainerDemoComponent, CarouselDemoComponent, VideoPlayerDemoComponent, ListDemoComponent, ModalDemoComponent, DrawerDemoComponent, DatePickerDemoComponent, TimePickerDemoComponent, GridSystemDemoComponent, SpacerDemoComponent, ContainerDemoComponent, PaginationDemoComponent, SkeletonLoaderDemoComponent, EmptyStateDemoComponent, FileUploadDemoComponent, FormFieldDemoComponent, AutocompleteDemoComponent, BackdropDemoComponent, OverlayContainerDemoComponent, LoadingSpinnerDemoComponent, ProgressCircleDemoComponent, RangeSliderDemoComponent, DividerDemoComponent, TooltipDemoComponent, AccordionDemoComponent, - PopoverDemoComponent, AlertDemoComponent, ToastDemoComponent, TreeViewDemoComponent, TimelineDemoComponent] + PopoverDemoComponent, AlertDemoComponent, SnackbarDemoComponent, ToastDemoComponent, TreeViewDemoComponent, TimelineDemoComponent, StepperDemoComponent, FabMenuDemoComponent, EnhancedTableDemoComponent, SplitButtonDemoComponent] }) diff --git a/projects/demo-ui-essentials/src/app/demos/split-button-demo/split-button-demo.component.scss b/projects/demo-ui-essentials/src/app/demos/split-button-demo/split-button-demo.component.scss new file mode 100644 index 0000000..3ac5457 --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/split-button-demo/split-button-demo.component.scss @@ -0,0 +1,63 @@ +@use '../../../../../shared-ui/src/styles/semantic/index' as *; + +.demo-container { + padding: $semantic-spacing-component-lg; + max-width: 1200px; + margin: 0 auto; +} + +.demo-section { + margin-bottom: $semantic-spacing-layout-section-lg; + + h3 { + font-family: map-get($semantic-typography-heading-h3, font-family); + font-size: map-get($semantic-typography-heading-h3, font-size); + font-weight: map-get($semantic-typography-heading-h3, font-weight); + line-height: map-get($semantic-typography-heading-h3, line-height); + color: $semantic-color-text-primary; + margin-bottom: $semantic-spacing-component-md; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-secondary; + padding-bottom: $semantic-spacing-component-sm; + } +} + +.demo-row { + display: flex; + flex-wrap: wrap; + gap: $semantic-spacing-component-md; + align-items: flex-start; + margin-bottom: $semantic-spacing-component-lg; +} + +.demo-results { + padding: $semantic-spacing-component-md; + background: $semantic-color-surface-secondary; + border: $semantic-border-width-1 solid $semantic-color-border-secondary; + border-radius: $semantic-border-radius-md; + + p { + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + margin: $semantic-spacing-component-xs 0; + + strong { + font-weight: $semantic-typography-font-weight-semibold; + color: $semantic-color-text-primary; + } + } +} + +// Responsive adjustments +@media (max-width: 768px) { + .demo-row { + flex-direction: column; + gap: $semantic-spacing-component-sm; + } + + .demo-container { + padding: $semantic-spacing-component-md; + } +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/demos/split-button-demo/split-button-demo.component.ts b/projects/demo-ui-essentials/src/app/demos/split-button-demo/split-button-demo.component.ts new file mode 100644 index 0000000..94ebde1 --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/split-button-demo/split-button-demo.component.ts @@ -0,0 +1,211 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { faDownload, faSave, faShare, faEdit, faTrash, faFile, faFileText, faPrint } from '@fortawesome/free-solid-svg-icons'; +import { SplitButtonComponent, SplitButtonMenuItem } from '../../../../../ui-essentials/src/lib/components/buttons/split-button'; + +@Component({ + selector: 'ui-split-button-demo', + standalone: true, + imports: [CommonModule, SplitButtonComponent], + template: ` +
+

Split Button Demo

+ + +
+

Sizes

+
+ @for (size of sizes; track size) { + + {{ size | titlecase }} Size + + } +
+
+ + +
+

Variants

+
+ @for (variant of variants; track variant) { + + {{ variant | titlecase }} + + } +
+
+ + +
+

With Icons

+
+ + Download + + + + Save + + + + Share + +
+
+ + +
+

States

+
+ + Disabled + + + + Loading + + + + Full Width + +
+
+ + +
+

Complex Examples

+
+ + Edit Document + + + + New File + +
+
+ + +
+

Interactive Results

+
+

Last Action: {{ lastAction || 'None' }}

+

Click Count: {{ clickCount }}

+
+
+
+ `, + styleUrl: './split-button-demo.component.scss' +}) +export class SplitButtonDemoComponent { + sizes = ['small', 'medium', 'large'] as const; + variants = ['filled', 'tonal', 'outlined'] as const; + + clickCount = 0; + lastAction = ''; + + // Icons + faDownload = faDownload; + faSave = faSave; + faShare = faShare; + faEdit = faEdit; + faTrash = faTrash; + faFile = faFile; + faFileText = faFileText; + faPrint = faPrint; + + // Menu configurations + basicMenuItems: SplitButtonMenuItem[] = [ + { label: 'Option 1', value: 'opt1' }, + { label: 'Option 2', value: 'opt2' }, + { label: 'Option 3', value: 'opt3' }, + { label: 'Disabled Option', value: 'disabled', disabled: true } + ]; + + exportMenuItems: SplitButtonMenuItem[] = [ + { label: 'Download PDF', value: 'pdf', icon: this.faFile }, + { label: 'Download CSV', value: 'csv', icon: this.faFileText }, + { label: 'Download Excel', value: 'excel', icon: this.faFile }, + { label: 'Print', value: 'print', icon: this.faPrint } + ]; + + saveMenuItems: SplitButtonMenuItem[] = [ + { label: 'Save As...', value: 'save-as' }, + { label: 'Save Copy', value: 'save-copy' }, + { label: 'Save Template', value: 'save-template' } + ]; + + shareMenuItems: SplitButtonMenuItem[] = [ + { label: 'Share Link', value: 'share-link' }, + { label: 'Share via Email', value: 'share-email' }, + { label: 'Export & Share', value: 'export-share' } + ]; + + editMenuItems: SplitButtonMenuItem[] = [ + { label: 'Quick Edit', value: 'quick-edit', icon: this.faEdit }, + { label: 'Advanced Edit', value: 'advanced-edit', icon: this.faEdit }, + { label: 'Delete', value: 'delete', icon: this.faTrash } + ]; + + fileMenuItems: SplitButtonMenuItem[] = [ + { label: 'New Text File', value: 'new-text', icon: this.faFileText }, + { label: 'New Document', value: 'new-doc', icon: this.faFile }, + { label: 'New from Template', value: 'new-template', icon: this.faFile } + ]; + + handlePrimaryClick(action: string): void { + this.clickCount++; + this.lastAction = action; + console.log('Primary button clicked:', action); + } + + handleMenuItemClick(data: { event: Event; item: SplitButtonMenuItem }): void { + this.clickCount++; + this.lastAction = `Menu item: ${data.item.label} (value: ${data.item.value})`; + console.log('Menu item clicked:', data); + } +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/features/dashboard/dashboard.component.ts b/projects/demo-ui-essentials/src/app/features/dashboard/dashboard.component.ts index 635ad54..1f0c112 100644 --- a/projects/demo-ui-essentials/src/app/features/dashboard/dashboard.component.ts +++ b/projects/demo-ui-essentials/src/app/features/dashboard/dashboard.component.ts @@ -10,7 +10,7 @@ import { faGripVertical, faArrowsAlt, faBoxOpen, faChevronLeft, faSpinner, faExclamationTriangle, faCloudUploadAlt, faFileText, faListAlt, faCircle, faExpandArrowsAlt, faCircleNotch, faSliders, faMinus, faInfoCircle, faChevronDown, faCaretUp, faExclamationCircle, faSitemap, faStream, - faBell + faBell, faRoute, faChevronUp, faEllipsisV, faCut } from '@fortawesome/free-solid-svg-icons'; import { DemoRoutes } from '../../demos'; import { ScrollContainerComponent } from '../../../../../ui-essentials/src/lib/layouts/scroll-container.component'; @@ -100,6 +100,10 @@ export class DashboardComponent { faSitemap = faSitemap; faStream = faStream; faBell = faBell; + faRoute = faRoute; + faChevronUp = faChevronUp; + faEllipsisV = faEllipsisV; + faCut = faCut; menuItems: any = [] @@ -153,6 +157,7 @@ export class DashboardComponent { const dataDisplayChildren = [ this.createChildItem("accordion", "Accordion", this.faChevronDown), this.createChildItem("table", "Tables", this.faTable), + this.createChildItem("enhanced-table", "Enhanced Table", this.faTable), this.createChildItem("lists", "Lists", this.faList), this.createChildItem("progress", "Progress Bars", this.faCheckSquare), this.createChildItem("badge", "Badges", this.faCertificate), @@ -168,8 +173,10 @@ export class DashboardComponent { // Navigation category const navigationChildren = [ this.createChildItem("appbar", "App Bars", this.faWindowMaximize), + this.createChildItem("bottom-navigation", "Bottom Navigation", this.faChevronUp), this.createChildItem("menu", "Menus", this.faBars), - this.createChildItem("pagination", "Pagination", this.faChevronLeft) + this.createChildItem("pagination", "Pagination", this.faChevronLeft), + this.createChildItem("stepper", "Stepper", this.faRoute) ]; this.addMenuItem("navigation", "Navigation", this.faCompass, navigationChildren); @@ -183,13 +190,16 @@ export class DashboardComponent { // Actions category const actionsChildren = [ - this.createChildItem("buttons", "Buttons", this.faMousePointer) + this.createChildItem("buttons", "Buttons", this.faMousePointer), + this.createChildItem("fab-menu", "FAB Menu", this.faEllipsisV), + this.createChildItem("split-button", "Split Button", this.faCut) ]; this.addMenuItem("actions", "Actions", this.faMousePointer, actionsChildren); // Feedback category const feedbackChildren = [ this.createChildItem("alert", "Alert", faExclamationCircle), + this.createChildItem("snackbar", "Snackbar", this.faBell), this.createChildItem("toast", "Toast", this.faBell), this.createChildItem("chips", "Chips", this.faTags), this.createChildItem("loading-spinner", "Loading Spinner", this.faSpinner), diff --git a/projects/ui-essentials/src/lib/components/buttons/index.ts b/projects/ui-essentials/src/lib/components/buttons/index.ts index 6435039..4a83a8a 100644 --- a/projects/ui-essentials/src/lib/components/buttons/index.ts +++ b/projects/ui-essentials/src/lib/components/buttons/index.ts @@ -2,4 +2,6 @@ export * from './button.component'; export * from './text-button.component'; export * from './ghost-button.component'; export * from './fab.component'; +export * from './fab-menu'; export * from './simple-button.component'; +export * from './split-button'; diff --git a/projects/ui-essentials/src/lib/components/buttons/split-button/index.ts b/projects/ui-essentials/src/lib/components/buttons/split-button/index.ts new file mode 100644 index 0000000..67fdfc6 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/buttons/split-button/index.ts @@ -0,0 +1 @@ +export * from './split-button.component'; \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/buttons/split-button/split-button.component.scss b/projects/ui-essentials/src/lib/components/buttons/split-button/split-button.component.scss new file mode 100644 index 0000000..2533134 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/buttons/split-button/split-button.component.scss @@ -0,0 +1,393 @@ +@use "../../../../../../shared-ui/src/styles/semantic/index" as *; + +.ui-split-button { + // Reset and base styles + display: inline-flex; + align-items: center; + position: relative; + border-radius: $semantic-border-radius-md; + overflow: hidden; + + // Size variants + &--small { + height: $semantic-spacing-9; // 2.25rem + font-size: $semantic-typography-font-size-sm; + line-height: $semantic-typography-line-height-tight; + } + + &--medium { + height: $semantic-spacing-11; // 2.75rem + font-size: $semantic-typography-font-size-md; + line-height: $semantic-typography-line-height-normal; + } + + &--large { + height: $semantic-spacing-12; // 3rem + font-size: $semantic-typography-font-size-lg; + line-height: $semantic-typography-line-height-normal; + } + + // Full width variant + &--full-width { + width: 100%; + } + + // Disabled state + &--disabled { + opacity: 0.38; + cursor: not-allowed; + pointer-events: none; + } + + // Primary button part + &__primary { + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + cursor: pointer; + text-decoration: none; + outline: none; + flex: 1; + + // Typography + font-family: $semantic-typography-font-family-sans; + font-weight: $semantic-typography-font-weight-medium; + text-align: center; + text-transform: none; + letter-spacing: $semantic-typography-letter-spacing-normal; + white-space: nowrap; + + // Interaction states + user-select: none; + -webkit-tap-highlight-color: transparent; + box-sizing: border-box; + + // Transitions + transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); + + // Size-specific padding + .ui-split-button--small & { + padding: 0 $semantic-spacing-3; // 0.75rem + } + + .ui-split-button--medium & { + padding: 0 $semantic-spacing-4; // 1rem + } + + .ui-split-button--large & { + padding: 0 $semantic-spacing-6; // 1.5rem + } + } + + // Dropdown button part + &__dropdown { + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + cursor: pointer; + outline: none; + position: relative; + + // Size-specific dimensions + .ui-split-button--small & { + width: $semantic-spacing-9; // 2.25rem + padding: 0 $semantic-spacing-2; // 0.5rem + } + + .ui-split-button--medium & { + width: $semantic-spacing-11; // 2.75rem + padding: 0 $semantic-spacing-3; // 0.75rem + } + + .ui-split-button--large & { + width: $semantic-spacing-12; // 3rem + padding: 0 $semantic-spacing-4; // 1rem + } + + // Interaction states + user-select: none; + -webkit-tap-highlight-color: transparent; + box-sizing: border-box; + + // Transitions + transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); + + // Divider between buttons + &::before { + content: ''; + position: absolute; + left: 0; + top: 20%; + height: 60%; + width: 1px; + background-color: currentColor; + opacity: 0.3; + } + } + + // Content wrapper + &__content { + display: flex; + align-items: center; + justify-content: center; + } + + // Icon styles + &__icon { + display: inline-flex; + align-items: center; + flex-shrink: 0; + + &--left { + margin-right: $semantic-spacing-2; // 0.5rem + } + + &--right { + margin-left: $semantic-spacing-2; // 0.5rem + } + + // Size-specific adjustments + .ui-split-button--small & { + font-size: $semantic-typography-font-size-xs; + + &--left { + margin-right: $semantic-spacing-1-5; // 0.375rem + } + + &--right { + margin-left: $semantic-spacing-1-5; // 0.375rem + } + } + + .ui-split-button--medium & { + font-size: $semantic-typography-font-size-sm; + } + + .ui-split-button--large & { + font-size: $semantic-typography-font-size-md; + + &--left { + margin-right: $semantic-spacing-2-5; // 0.625rem + } + + &--right { + margin-left: $semantic-spacing-2-5; // 0.625rem + } + } + } + + // Dropdown arrow + &__arrow { + display: inline-flex; + align-items: center; + font-size: 0.75em; + transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1); + } + + // Dropdown menu + &__menu { + position: absolute; + top: 100%; + left: 0; + min-width: 100%; + background: $semantic-color-surface-elevated; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-md; + box-shadow: $semantic-shadow-dropdown; + z-index: $semantic-z-index-dropdown; + opacity: 0; + visibility: hidden; + transform: translateY(-$semantic-spacing-2); + transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); + + &--open { + opacity: 1; + visibility: visible; + transform: translateY(0); + } + } + + // Menu item + &__menu-item { + display: flex; + align-items: center; + width: 100%; + padding: $semantic-spacing-3 $semantic-spacing-4; + border: none; + background: transparent; + color: $semantic-color-text-primary; + font-family: $semantic-typography-font-family-sans; + font-size: $semantic-typography-font-size-sm; + font-weight: $semantic-typography-font-weight-normal; + text-align: left; + cursor: pointer; + transition: background-color 200ms cubic-bezier(0.4, 0, 0.2, 1); + + &:hover { + background: $semantic-color-surface-container; + } + + &:first-child { + border-top-left-radius: $semantic-border-radius-md; + border-top-right-radius: $semantic-border-radius-md; + } + + &:last-child { + border-bottom-left-radius: $semantic-border-radius-md; + border-bottom-right-radius: $semantic-border-radius-md; + } + } + + // Filled variant (primary) + &--filled { + .ui-split-button__primary, + .ui-split-button__dropdown { + background-color: $semantic-color-interactive-primary; + color: $semantic-color-on-primary; + + &:hover:not(:disabled) { + background-color: $semantic-color-primary; + transform: translateY(-1px); + box-shadow: $semantic-shadow-button-hover; + } + + &:active:not(:disabled) { + transform: scale(0.98); + box-shadow: $semantic-shadow-button-active; + } + + &:focus-visible { + outline: 2px solid $semantic-color-primary; + outline-offset: 2px; + } + } + + .ui-split-button__dropdown:hover .ui-split-button__arrow { + transform: rotate(180deg); + } + } + + // Tonal variant + &--tonal { + .ui-split-button__primary, + .ui-split-button__dropdown { + background-color: $semantic-color-surface-container; + color: $semantic-color-text-primary; + + &:hover:not(:disabled) { + background-color: $semantic-color-surface-container; + transform: translateY(-1px); + box-shadow: $semantic-shadow-button-hover; + filter: brightness(0.95); + } + + &:active:not(:disabled) { + transform: scale(0.98); + } + + &:focus-visible { + outline: 2px solid $semantic-color-primary; + outline-offset: 2px; + } + } + + .ui-split-button__dropdown:hover .ui-split-button__arrow { + transform: rotate(180deg); + } + } + + // Outlined variant + &--outlined { + border: $semantic-border-width-1 solid $semantic-color-border-primary; + + .ui-split-button__primary, + .ui-split-button__dropdown { + background-color: transparent; + color: $semantic-color-interactive-primary; + + &:hover:not(:disabled) { + background-color: $semantic-color-surface-container; + transform: translateY(-1px); + } + + &:active:not(:disabled) { + background-color: $semantic-color-surface-container; + transform: scale(0.98); + filter: brightness(0.9); + } + + &:focus-visible { + outline: 2px solid $semantic-color-primary; + outline-offset: 2px; + } + } + + .ui-split-button__dropdown:hover .ui-split-button__arrow { + transform: rotate(180deg); + } + } + + // Loading state + &--loading { + .ui-split-button__primary { + cursor: wait; + + .ui-split-button__content { + opacity: 0; + } + } + + .ui-split-button__dropdown { + pointer-events: none; + opacity: 0.5; + } + } + + // Loader + &__loader { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + &__spinner { + width: $semantic-spacing-4; // 1rem + height: $semantic-spacing-4; // 1rem + border: 2px solid currentColor; + border-top: 2px solid transparent; + border-radius: 50%; + animation: spin 1s linear infinite; + opacity: 0.7; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + // Ripple effect + .ui-split-button__primary::after, + .ui-split-button__dropdown::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%); + transform: scale(0); + opacity: 0; + pointer-events: none; + transition: transform 0.3s ease, opacity 0.3s ease; + } + + .ui-split-button__primary:active:not(:disabled)::after, + .ui-split-button__dropdown:active:not(:disabled)::after { + transform: scale(1); + opacity: 1; + transition: none; + } +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/buttons/split-button/split-button.component.ts b/projects/ui-essentials/src/lib/components/buttons/split-button/split-button.component.ts new file mode 100644 index 0000000..616e5a9 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/buttons/split-button/split-button.component.ts @@ -0,0 +1,173 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, HostListener } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; +import { faChevronDown } from '@fortawesome/free-solid-svg-icons'; + +export type SplitButtonVariant = 'filled' | 'tonal' | 'outlined'; +export type SplitButtonSize = 'small' | 'medium' | 'large'; +export type SplitButtonIconPosition = 'left' | 'right'; + +export interface SplitButtonMenuItem { + label: string; + value?: any; + icon?: IconDefinition; + disabled?: boolean; +} + +@Component({ + selector: 'ui-split-button', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + + + + + + + + @if (menuItems && menuItems.length > 0) { + + } +
+ `, + styleUrl: './split-button.component.scss' +}) +export class SplitButtonComponent { + @Input() variant: SplitButtonVariant = 'filled'; + @Input() size: SplitButtonSize = 'medium'; + @Input() disabled: boolean = false; + @Input() loading: boolean = false; + @Input() type: 'button' | 'submit' | 'reset' = 'button'; + @Input() fullWidth: boolean = false; + @Input() class: string = ''; + @Input() icon?: IconDefinition; + @Input() iconPosition: SplitButtonIconPosition = 'left'; + @Input() menuItems: SplitButtonMenuItem[] = []; + + @Output() primaryClicked = new EventEmitter(); + @Output() menuItemClicked = new EventEmitter<{ event: Event; item: SplitButtonMenuItem }>(); + @Output() dropdownToggled = new EventEmitter(); + + isMenuOpen = false; + faChevronDown = faChevronDown; + + @HostListener('document:click', ['$event']) + onDocumentClick(event: Event): void { + const target = event.target as Element; + const splitButton = target.closest('.ui-split-button'); + + if (!splitButton || !splitButton.contains(target)) { + this.closeMenu(); + } + } + + @HostListener('document:keydown', ['$event']) + onDocumentKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape' && this.isMenuOpen) { + this.closeMenu(); + } + } + + handlePrimaryClick(event: Event): void { + if (!this.disabled && !this.loading) { + this.primaryClicked.emit(event); + this.closeMenu(); + } + } + + handleDropdownClick(event: Event): void { + if (!this.disabled && !this.loading) { + event.stopPropagation(); + this.toggleMenu(); + } + } + + handleMenuItemClick(event: Event, item: SplitButtonMenuItem): void { + if (!item.disabled) { + event.stopPropagation(); + this.menuItemClicked.emit({ event, item }); + this.closeMenu(); + } + } + + private toggleMenu(): void { + this.isMenuOpen = !this.isMenuOpen; + this.dropdownToggled.emit(this.isMenuOpen); + } + + private closeMenu(): void { + if (this.isMenuOpen) { + this.isMenuOpen = false; + this.dropdownToggled.emit(false); + } + } +} \ No newline at end of file