Skip to content

Commit e6373fc

Browse files
committed
Update Toolbar
1 parent 1ff2ceb commit e6373fc

24 files changed

+806
-27
lines changed

libs/brain/toggle-group/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# @spartan-ng/brain/toggle-group
2+
3+
Secondary entry point of `@spartan-ng/brain`. It can be used by importing from `@spartan-ng/brain/toggle-group`.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"lib": {
3+
"entryFile": "src/index.ts"
4+
}
5+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { BrnToggleGroup } from './lib/brn-toggle-group';
2+
import { BrnToggleGroupItem } from './lib/brn-toggle-item';
3+
4+
export * from './lib/brn-toggle-group';
5+
export * from './lib/brn-toggle-group.token';
6+
export * from './lib/brn-toggle-item';
7+
8+
export const BrnToggleGroupImports = [BrnToggleGroup, BrnToggleGroupItem] as const;
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { ChangeDetectionStrategy, Component, input, model } from '@angular/core';
2+
import { FormsModule } from '@angular/forms';
3+
import { fireEvent, render } from '@testing-library/angular';
4+
import { BrnToggleGroup } from './brn-toggle-group';
5+
import { BrnToggleGroupItem } from './brn-toggle-item';
6+
7+
@Component({
8+
imports: [BrnToggleGroupItem, BrnToggleGroup],
9+
template: `
10+
<brn-toggle-group [(value)]="value" [disabled]="disabled()" [type]="type()">
11+
<button brnToggleGroupItem value="option-1">Option 1</button>
12+
<button brnToggleGroupItem value="option-2">Option 2</button>
13+
<button brnToggleGroupItem value="option-3">Option 3</button>
14+
</brn-toggle-group>
15+
`,
16+
changeDetection: ChangeDetectionStrategy.OnPush,
17+
})
18+
class BrnToggleGroupDirectiveSpec {
19+
public readonly value? = model<string | string[]>();
20+
public readonly disabled = input(false);
21+
public readonly type = input('single');
22+
}
23+
24+
@Component({
25+
imports: [BrnToggleGroupItem, BrnToggleGroup, FormsModule],
26+
template: `
27+
<brn-toggle-group [(ngModel)]="value" [type]="type()">
28+
<button brnToggleGroupItem value="option-1">Option 1</button>
29+
<button brnToggleGroupItem value="option-2">Option 2</button>
30+
<button brnToggleGroupItem value="option-3">Option 3</button>
31+
</brn-toggle-group>
32+
`,
33+
changeDetection: ChangeDetectionStrategy.OnPush,
34+
})
35+
class BrnToggleGroupDirectiveFormSpec {
36+
public readonly value = model<string | string[]>();
37+
public readonly type = input('single');
38+
}
39+
40+
describe('BrnToggleGroupDirective', () => {
41+
it('should allow only a single selected toggle button when type is single', async () => {
42+
const { getAllByRole } = await render(BrnToggleGroupDirectiveSpec);
43+
const buttons = getAllByRole('button');
44+
45+
expect(buttons[0]).toHaveAttribute('data-state', 'off');
46+
expect(buttons[1]).toHaveAttribute('data-state', 'off');
47+
expect(buttons[2]).toHaveAttribute('data-state', 'off');
48+
49+
await fireEvent.click(buttons[0]);
50+
expect(buttons[0]).toHaveAttribute('data-state', 'on');
51+
expect(buttons[1]).toHaveAttribute('data-state', 'off');
52+
expect(buttons[2]).toHaveAttribute('data-state', 'off');
53+
54+
await fireEvent.click(buttons[1]);
55+
expect(buttons[0]).toHaveAttribute('data-state', 'off');
56+
expect(buttons[1]).toHaveAttribute('data-state', 'on');
57+
expect(buttons[2]).toHaveAttribute('data-state', 'off');
58+
});
59+
60+
it('should allow multiple selected toggle buttons when type is multiple', async () => {
61+
const { getAllByRole, detectChanges } = await render(BrnToggleGroupDirectiveSpec, {
62+
inputs: {
63+
type: 'multiple',
64+
},
65+
});
66+
const buttons = getAllByRole('button');
67+
68+
expect(buttons[0]).toHaveAttribute('data-state', 'off');
69+
expect(buttons[1]).toHaveAttribute('data-state', 'off');
70+
expect(buttons[2]).toHaveAttribute('data-state', 'off');
71+
72+
await fireEvent.click(buttons[0]);
73+
detectChanges();
74+
expect(buttons[0]).toHaveAttribute('data-state', 'on');
75+
expect(buttons[1]).toHaveAttribute('data-state', 'off');
76+
expect(buttons[2]).toHaveAttribute('data-state', 'off');
77+
78+
await fireEvent.click(buttons[1]);
79+
detectChanges();
80+
expect(buttons[0]).toHaveAttribute('data-state', 'on');
81+
expect(buttons[1]).toHaveAttribute('data-state', 'on');
82+
expect(buttons[2]).toHaveAttribute('data-state', 'off');
83+
});
84+
85+
it('should disable all toggle buttons when disabled is true', async () => {
86+
const { getAllByRole } = await render(BrnToggleGroupDirectiveSpec, {
87+
inputs: {
88+
disabled: true,
89+
},
90+
});
91+
const buttons = getAllByRole('button');
92+
93+
expect(buttons[0]).toHaveAttribute('disabled');
94+
expect(buttons[1]).toHaveAttribute('disabled');
95+
expect(buttons[2]).toHaveAttribute('disabled');
96+
});
97+
98+
it('should initially select the button with the provided value (type = single)', async () => {
99+
const { getAllByRole, detectChanges } = await render(BrnToggleGroupDirectiveFormSpec, {
100+
inputs: {
101+
value: 'option-2',
102+
},
103+
});
104+
detectChanges();
105+
const buttons = getAllByRole('button');
106+
107+
expect(buttons[0]).toHaveAttribute('data-state', 'off');
108+
expect(buttons[1]).toHaveAttribute('data-state', 'on');
109+
expect(buttons[2]).toHaveAttribute('data-state', 'off');
110+
});
111+
112+
it('should initially select the buttons with the provided values (type = multiple)', async () => {
113+
const { getAllByRole, detectChanges } = await render(BrnToggleGroupDirectiveFormSpec, {
114+
inputs: {
115+
value: ['option-1', 'option-3'],
116+
type: 'multiple',
117+
},
118+
});
119+
detectChanges();
120+
const buttons = getAllByRole('button');
121+
122+
expect(buttons[0]).toHaveAttribute('data-state', 'on');
123+
expect(buttons[1]).toHaveAttribute('data-state', 'off');
124+
expect(buttons[2]).toHaveAttribute('data-state', 'on');
125+
});
126+
127+
it('should initially select the button with the provided value (type = single) using ngModel', async () => {
128+
const { getAllByRole, detectChanges } = await render(BrnToggleGroupDirectiveFormSpec, {
129+
inputs: {
130+
value: 'option-2',
131+
},
132+
});
133+
detectChanges();
134+
const buttons = getAllByRole('button');
135+
136+
expect(buttons[0]).toHaveAttribute('data-state', 'off');
137+
expect(buttons[1]).toHaveAttribute('data-state', 'on');
138+
expect(buttons[2]).toHaveAttribute('data-state', 'off');
139+
});
140+
141+
it('should initially select the buttons with the provided values (type = multiple) using ngModel', async () => {
142+
const { getAllByRole, detectChanges } = await render(BrnToggleGroupDirectiveFormSpec, {
143+
inputs: {
144+
value: ['option-1', 'option-3'],
145+
type: 'multiple',
146+
},
147+
});
148+
detectChanges();
149+
const buttons = getAllByRole('button');
150+
151+
expect(buttons[0]).toHaveAttribute('data-state', 'on');
152+
expect(buttons[1]).toHaveAttribute('data-state', 'off');
153+
expect(buttons[2]).toHaveAttribute('data-state', 'on');
154+
});
155+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { type ExistingProvider, InjectionToken, type Type, inject } from '@angular/core';
2+
import type { BrnToggleGroup } from './brn-toggle-group';
3+
4+
const BrnToggleGroupToken = new InjectionToken<BrnToggleGroup>('BrnToggleGroupToken');
5+
6+
export function injectBrnToggleGroup<T>(): BrnToggleGroup<T> | null {
7+
return inject(BrnToggleGroupToken, { optional: true }) as BrnToggleGroup<T> | null;
8+
}
9+
10+
export function provideBrnToggleGroup<T>(value: Type<BrnToggleGroup<T>>): ExistingProvider {
11+
return { provide: BrnToggleGroupToken, useExisting: value };
12+
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import type { BooleanInput } from '@angular/cdk/coercion';
2+
import { booleanAttribute, computed, Directive, forwardRef, input, linkedSignal, model, output } from '@angular/core';
3+
import { type ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
4+
import { provideBrnToggleGroup } from './brn-toggle-group.token';
5+
import type { BrnToggleGroupItem } from './brn-toggle-item';
6+
7+
export const BRN_BUTTON_TOGGLE_GROUP_VALUE_ACCESSOR = {
8+
provide: NG_VALUE_ACCESSOR,
9+
useExisting: forwardRef(() => BrnToggleGroup),
10+
multi: true,
11+
};
12+
13+
export class BrnButtonToggleChange<T = unknown> {
14+
constructor(
15+
public source: BrnToggleGroupItem<T>,
16+
public value: ToggleValue<T>,
17+
) {}
18+
}
19+
20+
@Directive({
21+
selector: '[brnToggleGroup],brn-toggle-group',
22+
providers: [provideBrnToggleGroup(BrnToggleGroup), BRN_BUTTON_TOGGLE_GROUP_VALUE_ACCESSOR],
23+
host: {
24+
role: 'group',
25+
'[attr.aria-disabled]': 'disabledState()',
26+
'[attr.data-disabled]': 'disabledState()',
27+
'(focusout)': 'onTouched()',
28+
},
29+
exportAs: 'brnToggleGroup',
30+
})
31+
export class BrnToggleGroup<T = unknown> implements ControlValueAccessor {
32+
/** The type of the toggle group. */
33+
public readonly type = input<ToggleType>('single');
34+
35+
/** Whether the toggle group allows multiple selections. */
36+
protected readonly _multiple = computed(() => this.type() === 'multiple');
37+
38+
/** Value of the toggle group. */
39+
public readonly value = model<ToggleValue<T>>(undefined);
40+
41+
/** Emits when the value changes. */
42+
public readonly valueChange = output<ToggleValue<T>>();
43+
44+
/** Whether no button toggles need to be selected. */
45+
public readonly nullable = input<boolean, BooleanInput>(false, {
46+
transform: booleanAttribute,
47+
});
48+
49+
/** Whether the button toggle group is disabled. */
50+
public readonly disabled = input<boolean, BooleanInput>(false, {
51+
transform: booleanAttribute,
52+
});
53+
54+
/** The disabled state. */
55+
public readonly disabledState = linkedSignal(this.disabled);
56+
57+
/** Emit event when the group value changes. */
58+
public readonly change = output<BrnButtonToggleChange<T>>();
59+
60+
/**
61+
* The method to be called in order to update ngModel.
62+
*/
63+
// eslint-disable-next-line @typescript-eslint/no-empty-function
64+
private _onChange: (value: ToggleValue<T>) => void = () => {};
65+
66+
/** onTouch function registered via registerOnTouch (ControlValueAccessor). */
67+
// eslint-disable-next-line @typescript-eslint/no-empty-function
68+
protected onTouched: () => void = () => {};
69+
70+
writeValue(value: ToggleValue<T>): void {
71+
this.value.set(value);
72+
}
73+
74+
registerOnChange(fn: (value: ToggleValue<T>) => void) {
75+
this._onChange = fn;
76+
}
77+
78+
registerOnTouched(fn: () => void) {
79+
this.onTouched = fn;
80+
}
81+
82+
setDisabledState(isDisabled: boolean): void {
83+
this.disabledState.set(isDisabled);
84+
}
85+
86+
/**
87+
* @internal
88+
* Determines whether a value can be set on the group.
89+
*/
90+
canDeselect(value: ToggleValue<T>): boolean {
91+
// if null values are allowed, the group can always be nullable
92+
if (this.nullable()) return true;
93+
94+
const currentValue = this.value();
95+
96+
if (this._multiple() && Array.isArray(currentValue)) {
97+
return !(currentValue.length === 1 && currentValue[0] === value);
98+
}
99+
100+
return currentValue !== value;
101+
}
102+
103+
/**
104+
* @internal
105+
* Selects a value.
106+
*/
107+
select(value: T, source: BrnToggleGroupItem<T>): void {
108+
if (this.disabledState() || this.isSelected(value)) {
109+
return;
110+
}
111+
112+
const currentValue = this.value();
113+
114+
// emit the valueChange event here as we should only emit based on user interaction
115+
if (this._multiple()) {
116+
this.emitSelectionChange([...((currentValue ?? []) as T[]), value], source);
117+
} else {
118+
this.emitSelectionChange(value, source);
119+
}
120+
121+
this._onChange(this.value());
122+
this.change.emit(new BrnButtonToggleChange<T>(source, this.value()));
123+
}
124+
125+
/**
126+
* @internal
127+
* Deselects a value.
128+
*/
129+
deselect(value: T, source: BrnToggleGroupItem<T>): void {
130+
if (this.disabledState() || !this.isSelected(value) || !this.canDeselect(value)) {
131+
return;
132+
}
133+
134+
const currentValue = this.value();
135+
136+
if (this._multiple()) {
137+
this.emitSelectionChange(
138+
((currentValue ?? []) as T[]).filter((v) => v !== value),
139+
source,
140+
);
141+
} else if (currentValue === value) {
142+
this.emitSelectionChange(null, source);
143+
}
144+
}
145+
146+
/**
147+
* @internal
148+
* Determines whether a value is selected.
149+
*/
150+
isSelected(value: T): boolean {
151+
const currentValue = this.value();
152+
153+
if (
154+
currentValue == null ||
155+
currentValue === undefined ||
156+
(Array.isArray(currentValue) && currentValue.length === 0)
157+
) {
158+
return false;
159+
}
160+
161+
if (this._multiple()) {
162+
return (currentValue as T[])?.includes(value);
163+
}
164+
return currentValue === value;
165+
}
166+
167+
/** Update the value of the group */
168+
private emitSelectionChange(value: ToggleValue<T>, source: BrnToggleGroupItem<T>): void {
169+
this.value.set(value);
170+
this.valueChange.emit(value);
171+
this._onChange(value);
172+
this.change.emit(new BrnButtonToggleChange<T>(source, this.value()));
173+
}
174+
}
175+
176+
export type ToggleValue<T> = T | T[] | null | undefined;
177+
export type ToggleType = 'single' | 'multiple';

0 commit comments

Comments
 (0)