Add split button component with comprehensive functionality
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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';
|
||||
<ui-appbar-demo></ui-appbar-demo>
|
||||
}
|
||||
|
||||
@case ("bottom-navigation") {
|
||||
<ui-bottom-navigation-demo></ui-bottom-navigation-demo>
|
||||
}
|
||||
|
||||
@case ("avatar") {
|
||||
<ui-avatar-demo></ui-avatar-demo>
|
||||
}
|
||||
@@ -231,6 +241,10 @@ import { TimelineDemoComponent } from './timeline-demo/timeline-demo.component';
|
||||
<ui-alert-demo></ui-alert-demo>
|
||||
}
|
||||
|
||||
@case ("snackbar") {
|
||||
<ui-snackbar-demo></ui-snackbar-demo>
|
||||
}
|
||||
|
||||
@case ("toast") {
|
||||
<ui-toast-demo></ui-toast-demo>
|
||||
}
|
||||
@@ -243,6 +257,22 @@ import { TimelineDemoComponent } from './timeline-demo/timeline-demo.component';
|
||||
<ui-timeline-demo></ui-timeline-demo>
|
||||
}
|
||||
|
||||
@case ("stepper") {
|
||||
<ui-stepper-demo></ui-stepper-demo>
|
||||
}
|
||||
|
||||
@case ("fab-menu") {
|
||||
<ui-fab-menu-demo></ui-fab-menu-demo>
|
||||
}
|
||||
|
||||
@case ("enhanced-table") {
|
||||
<ui-enhanced-table-demo></ui-enhanced-table-demo>
|
||||
}
|
||||
|
||||
@case ("split-button") {
|
||||
<ui-split-button-demo></ui-split-button-demo>
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
`,
|
||||
@@ -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]
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<div class="demo-container">
|
||||
<h2>Split Button Demo</h2>
|
||||
|
||||
<!-- Size Variants -->
|
||||
<section class="demo-section">
|
||||
<h3>Sizes</h3>
|
||||
<div class="demo-row">
|
||||
@for (size of sizes; track size) {
|
||||
<ui-split-button
|
||||
[size]="size"
|
||||
[menuItems]="basicMenuItems"
|
||||
(primaryClicked)="handlePrimaryClick('Primary clicked - ' + size)"
|
||||
(menuItemClicked)="handleMenuItemClick($event)">
|
||||
{{ size | titlecase }} Size
|
||||
</ui-split-button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Variant Styles -->
|
||||
<section class="demo-section">
|
||||
<h3>Variants</h3>
|
||||
<div class="demo-row">
|
||||
@for (variant of variants; track variant) {
|
||||
<ui-split-button
|
||||
[variant]="variant"
|
||||
[menuItems]="basicMenuItems"
|
||||
(primaryClicked)="handlePrimaryClick('Primary clicked - ' + variant)"
|
||||
(menuItemClicked)="handleMenuItemClick($event)">
|
||||
{{ variant | titlecase }}
|
||||
</ui-split-button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- With Icons -->
|
||||
<section class="demo-section">
|
||||
<h3>With Icons</h3>
|
||||
<div class="demo-row">
|
||||
<ui-split-button
|
||||
[icon]="faDownload"
|
||||
iconPosition="left"
|
||||
[menuItems]="exportMenuItems"
|
||||
(primaryClicked)="handlePrimaryClick('Download clicked')"
|
||||
(menuItemClicked)="handleMenuItemClick($event)">
|
||||
Download
|
||||
</ui-split-button>
|
||||
|
||||
<ui-split-button
|
||||
variant="tonal"
|
||||
[icon]="faSave"
|
||||
iconPosition="right"
|
||||
[menuItems]="saveMenuItems"
|
||||
(primaryClicked)="handlePrimaryClick('Save clicked')"
|
||||
(menuItemClicked)="handleMenuItemClick($event)">
|
||||
Save
|
||||
</ui-split-button>
|
||||
|
||||
<ui-split-button
|
||||
variant="outlined"
|
||||
[icon]="faShare"
|
||||
[menuItems]="shareMenuItems"
|
||||
(primaryClicked)="handlePrimaryClick('Share clicked')"
|
||||
(menuItemClicked)="handleMenuItemClick($event)">
|
||||
Share
|
||||
</ui-split-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- States -->
|
||||
<section class="demo-section">
|
||||
<h3>States</h3>
|
||||
<div class="demo-row">
|
||||
<ui-split-button
|
||||
[disabled]="true"
|
||||
[menuItems]="basicMenuItems">
|
||||
Disabled
|
||||
</ui-split-button>
|
||||
|
||||
<ui-split-button
|
||||
[loading]="true"
|
||||
[menuItems]="basicMenuItems">
|
||||
Loading
|
||||
</ui-split-button>
|
||||
|
||||
<ui-split-button
|
||||
[fullWidth]=true
|
||||
variant="tonal"
|
||||
[menuItems]="basicMenuItems"
|
||||
(primaryClicked)="handlePrimaryClick('Full width clicked')"
|
||||
(menuItemClicked)="handleMenuItemClick($event)">
|
||||
Full Width
|
||||
</ui-split-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Complex Examples -->
|
||||
<section class="demo-section">
|
||||
<h3>Complex Examples</h3>
|
||||
<div class="demo-row">
|
||||
<ui-split-button
|
||||
size="large"
|
||||
variant="filled"
|
||||
[icon]="faEdit"
|
||||
[menuItems]="editMenuItems"
|
||||
(primaryClicked)="handlePrimaryClick('Edit Document clicked')"
|
||||
(menuItemClicked)="handleMenuItemClick($event)">
|
||||
Edit Document
|
||||
</ui-split-button>
|
||||
|
||||
<ui-split-button
|
||||
size="medium"
|
||||
variant="outlined"
|
||||
[menuItems]="fileMenuItems"
|
||||
(primaryClicked)="handlePrimaryClick('New File clicked')"
|
||||
(menuItemClicked)="handleMenuItemClick($event)">
|
||||
New File
|
||||
</ui-split-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Interactive Results -->
|
||||
<section class="demo-section">
|
||||
<h3>Interactive Results</h3>
|
||||
<div class="demo-results">
|
||||
<p><strong>Last Action:</strong> {{ lastAction || 'None' }}</p>
|
||||
<p><strong>Click Count:</strong> {{ clickCount }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './split-button.component';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<div
|
||||
class="ui-split-button"
|
||||
[class.ui-split-button--{{size}}]="size"
|
||||
[class.ui-split-button--{{variant}}]="variant"
|
||||
[class.ui-split-button--disabled]="disabled"
|
||||
[class.ui-split-button--loading]="loading"
|
||||
[class.ui-split-button--full-width]="fullWidth">
|
||||
|
||||
<!-- Primary button -->
|
||||
<button
|
||||
class="ui-split-button__primary"
|
||||
[disabled]="disabled || loading"
|
||||
[type]="type"
|
||||
(click)="handlePrimaryClick($event)">
|
||||
|
||||
@if (loading) {
|
||||
<div class="ui-split-button__loader">
|
||||
<div class="ui-split-button__spinner"></div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="ui-split-button__content">
|
||||
@if (icon && iconPosition === 'left') {
|
||||
<fa-icon [icon]="icon" class="ui-split-button__icon ui-split-button__icon--left"></fa-icon>
|
||||
}
|
||||
<span class="ui-split-button__text">
|
||||
<ng-content></ng-content>
|
||||
</span>
|
||||
@if (icon && iconPosition === 'right') {
|
||||
<fa-icon [icon]="icon" class="ui-split-button__icon ui-split-button__icon--right"></fa-icon>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Dropdown button -->
|
||||
<button
|
||||
class="ui-split-button__dropdown"
|
||||
[disabled]="disabled || loading"
|
||||
type="button"
|
||||
[attr.aria-expanded]="isMenuOpen"
|
||||
[attr.aria-haspopup]="'menu'"
|
||||
(click)="handleDropdownClick($event)">
|
||||
|
||||
<fa-icon
|
||||
[icon]="faChevronDown"
|
||||
class="ui-split-button__arrow">
|
||||
</fa-icon>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown menu -->
|
||||
@if (menuItems && menuItems.length > 0) {
|
||||
<div
|
||||
class="ui-split-button__menu"
|
||||
[class.ui-split-button__menu--open]="isMenuOpen"
|
||||
role="menu"
|
||||
[attr.aria-hidden]="!isMenuOpen">
|
||||
|
||||
@for (item of menuItems; track item.label) {
|
||||
<button
|
||||
class="ui-split-button__menu-item"
|
||||
role="menuitem"
|
||||
[disabled]="item.disabled"
|
||||
(click)="handleMenuItemClick($event, item)">
|
||||
|
||||
@if (item.icon) {
|
||||
<fa-icon
|
||||
[icon]="item.icon"
|
||||
class="ui-split-button__icon ui-split-button__icon--left">
|
||||
</fa-icon>
|
||||
}
|
||||
{{ item.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
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<Event>();
|
||||
@Output() menuItemClicked = new EventEmitter<{ event: Event; item: SplitButtonMenuItem }>();
|
||||
@Output() dropdownToggled = new EventEmitter<boolean>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user