| 
 | 1 | +import { ClassValue } from 'clsx';  | 
 | 2 | + | 
 | 3 | +import { ChangeDetectionStrategy, Component, computed, forwardRef, input, output, signal, ViewEncapsulation } from '@angular/core';  | 
 | 4 | +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';  | 
 | 5 | + | 
 | 6 | +import { mergeClasses } from '@shared/utils/merge-classes';  | 
 | 7 | +import { toggleGroupVariants, toggleGroupItemVariants } from './toggle-group.variants';  | 
 | 8 | + | 
 | 9 | +export interface ZardToggleGroupItem {  | 
 | 10 | +  value: string;  | 
 | 11 | +  label?: string;  | 
 | 12 | +  icon?: string;  | 
 | 13 | +  disabled?: boolean;  | 
 | 14 | +  ariaLabel?: string;  | 
 | 15 | +}  | 
 | 16 | + | 
 | 17 | +type OnTouchedType = () => void;  | 
 | 18 | +type OnChangeType = (value: string | string[]) => void;  | 
 | 19 | + | 
 | 20 | +@Component({  | 
 | 21 | +  selector: 'z-toggle-group',  | 
 | 22 | +  exportAs: 'zToggleGroup',  | 
 | 23 | +  standalone: true,  | 
 | 24 | +  changeDetection: ChangeDetectionStrategy.OnPush,  | 
 | 25 | +  encapsulation: ViewEncapsulation.None,  | 
 | 26 | +  template: `  | 
 | 27 | +    <div [class]="classes()" role="group" [attr.data-orientation]="'horizontal'">  | 
 | 28 | +      @for (item of items(); track item.value; let i = $index) {  | 
 | 29 | +        <button  | 
 | 30 | +          type="button"  | 
 | 31 | +          [attr.aria-pressed]="isItemPressed(item.value)"  | 
 | 32 | +          [attr.data-state]="isItemPressed(item.value) ? 'on' : 'off'"  | 
 | 33 | +          [attr.aria-label]="item.ariaLabel"  | 
 | 34 | +          [class]="getItemClasses(i, items().length)"  | 
 | 35 | +          [disabled]="disabled() || item.disabled"  | 
 | 36 | +          (click)="toggleItem(item)"  | 
 | 37 | +        >  | 
 | 38 | +          @if (item.icon) {  | 
 | 39 | +            <span [class]="item.icon + ' w-4 h-4 shrink-0'"></span>  | 
 | 40 | +          }  | 
 | 41 | +          @if (item.label) {  | 
 | 42 | +            <span>{{ item.label }}</span>  | 
 | 43 | +          } @else if (!item.icon) {  | 
 | 44 | +            <span>{{ item.value }}</span>  | 
 | 45 | +          }  | 
 | 46 | +        </button>  | 
 | 47 | +      }  | 
 | 48 | +    </div>  | 
 | 49 | +  `,  | 
 | 50 | +  providers: [  | 
 | 51 | +    {  | 
 | 52 | +      provide: NG_VALUE_ACCESSOR,  | 
 | 53 | +      useExisting: forwardRef(() => ZardToggleGroupComponent),  | 
 | 54 | +      multi: true,  | 
 | 55 | +    },  | 
 | 56 | +  ],  | 
 | 57 | +})  | 
 | 58 | +export class ZardToggleGroupComponent implements ControlValueAccessor {  | 
 | 59 | +  readonly zMode = input<'single' | 'multiple'>('multiple');  | 
 | 60 | +  readonly zType = input<'default' | 'outline'>('default');  | 
 | 61 | +  readonly zSize = input<'sm' | 'md' | 'lg'>('md');  | 
 | 62 | +  readonly value = input<string | string[]>();  | 
 | 63 | +  readonly defaultValue = input<string | string[]>();  | 
 | 64 | +  readonly disabled = input<boolean>(false);  | 
 | 65 | +  readonly class = input<ClassValue>('');  | 
 | 66 | +  readonly items = input<ZardToggleGroupItem[]>([]);  | 
 | 67 | + | 
 | 68 | +  readonly valueChange = output<string | string[]>();  | 
 | 69 | + | 
 | 70 | +  private internalValue = signal<string | string[] | undefined>(undefined);  | 
 | 71 | + | 
 | 72 | +  protected readonly classes = computed(() =>  | 
 | 73 | +    mergeClasses(  | 
 | 74 | +      toggleGroupVariants({  | 
 | 75 | +        zType: this.zType(),  | 
 | 76 | +        zSize: this.zSize(),  | 
 | 77 | +      }),  | 
 | 78 | +      this.class(),  | 
 | 79 | +    ),  | 
 | 80 | +  );  | 
 | 81 | + | 
 | 82 | +  protected readonly currentValue = computed(() => {  | 
 | 83 | +    const internal = this.internalValue();  | 
 | 84 | +    const input = this.value();  | 
 | 85 | +    const defaultVal = this.defaultValue();  | 
 | 86 | + | 
 | 87 | +    if (internal !== undefined) return internal;  | 
 | 88 | +    if (input !== undefined) return input;  | 
 | 89 | +    if (defaultVal !== undefined) return defaultVal;  | 
 | 90 | + | 
 | 91 | +    return this.zMode() === 'single' ? '' : [];  | 
 | 92 | +  });  | 
 | 93 | + | 
 | 94 | +  protected getItemClasses(index: number, total: number): string {  | 
 | 95 | +    const baseClasses = toggleGroupItemVariants({  | 
 | 96 | +      zType: this.zType(),  | 
 | 97 | +      zSize: this.zSize(),  | 
 | 98 | +    });  | 
 | 99 | + | 
 | 100 | +    const positionClasses = [];  | 
 | 101 | + | 
 | 102 | +    // Add rounded corners for first and last items  | 
 | 103 | +    if (index === 0) {  | 
 | 104 | +      positionClasses.push('first:rounded-l-md');  | 
 | 105 | +    }  | 
 | 106 | +    if (index === total - 1) {  | 
 | 107 | +      positionClasses.push('last:rounded-r-md');  | 
 | 108 | +    }  | 
 | 109 | + | 
 | 110 | +    // Handle borders for outline variant  | 
 | 111 | +    if (this.zType() === 'outline') {  | 
 | 112 | +      if (index === 0) {  | 
 | 113 | +        // First item gets full border  | 
 | 114 | +        positionClasses.push('border-l');  | 
 | 115 | +      } else {  | 
 | 116 | +        // Other items don't get left border (connects to previous)  | 
 | 117 | +        positionClasses.push('border-l-0');  | 
 | 118 | +      }  | 
 | 119 | +    }  | 
 | 120 | + | 
 | 121 | +    // Focus z-index  | 
 | 122 | +    positionClasses.push('focus:z-10', 'focus-visible:z-10');  | 
 | 123 | + | 
 | 124 | +    return mergeClasses(baseClasses, ...positionClasses);  | 
 | 125 | +  }  | 
 | 126 | + | 
 | 127 | +  protected isItemPressed(itemValue: string): boolean {  | 
 | 128 | +    const current = this.currentValue();  | 
 | 129 | +    if (this.zMode() === 'single') {  | 
 | 130 | +      return current === itemValue;  | 
 | 131 | +    }  | 
 | 132 | +    return Array.isArray(current) && current.includes(itemValue);  | 
 | 133 | +  }  | 
 | 134 | + | 
 | 135 | +  // eslint-disable-next-line @typescript-eslint/no-empty-function  | 
 | 136 | +  private onTouched: OnTouchedType = () => {};  | 
 | 137 | +  // eslint-disable-next-line @typescript-eslint/no-empty-function  | 
 | 138 | +  private onChangeFn: OnChangeType = () => {};  | 
 | 139 | + | 
 | 140 | +  toggleItem(item: ZardToggleGroupItem) {  | 
 | 141 | +    if (this.disabled() || item.disabled) return;  | 
 | 142 | + | 
 | 143 | +    const currentValue = this.currentValue();  | 
 | 144 | +    let newValue: string | string[];  | 
 | 145 | + | 
 | 146 | +    if (this.zMode() === 'single') {  | 
 | 147 | +      newValue = currentValue === item.value ? '' : item.value;  | 
 | 148 | +    } else {  | 
 | 149 | +      const currentArray = Array.isArray(currentValue) ? currentValue : [];  | 
 | 150 | +      if (currentArray.includes(item.value)) {  | 
 | 151 | +        newValue = currentArray.filter(v => v !== item.value);  | 
 | 152 | +      } else {  | 
 | 153 | +        newValue = [...currentArray, item.value];  | 
 | 154 | +      }  | 
 | 155 | +    }  | 
 | 156 | + | 
 | 157 | +    this.internalValue.set(newValue);  | 
 | 158 | +    this.valueChange.emit(newValue);  | 
 | 159 | +    this.onChangeFn(newValue);  | 
 | 160 | +    this.onTouched();  | 
 | 161 | +  }  | 
 | 162 | + | 
 | 163 | +  writeValue(value: string | string[]): void {  | 
 | 164 | +    if (value !== undefined) {  | 
 | 165 | +      this.internalValue.set(value);  | 
 | 166 | +    }  | 
 | 167 | +  }  | 
 | 168 | + | 
 | 169 | +  registerOnChange(fn: OnChangeType): void {  | 
 | 170 | +    this.onChangeFn = fn;  | 
 | 171 | +  }  | 
 | 172 | + | 
 | 173 | +  registerOnTouched(fn: OnTouchedType): void {  | 
 | 174 | +    this.onTouched = fn;  | 
 | 175 | +  }  | 
 | 176 | + | 
 | 177 | +  setDisabledState(_isDisabled: boolean): void {  | 
 | 178 | +    // Note: disabled state is handled through the disabled input  | 
 | 179 | +    // This method is required by ControlValueAccessor interface  | 
 | 180 | +  }  | 
 | 181 | +}  | 
0 commit comments