Skip to content

Commit e0854a8

Browse files
committed
feat(material/button): add expanded option for extended FAB
Adds the `expanded` input to the extended FAB component, allowing developers to collapse the extended FAB down to a regular circular shape while hiding the text label via animations. By default, `expanded` is `true`.
1 parent 0f5b0ee commit e0854a8

File tree

8 files changed

+173
-23
lines changed

8 files changed

+173
-23
lines changed

MODULE.bazel.lock

Lines changed: 0 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

goldens/material/button/index.api.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,17 @@ export type MatFabAnchor = MatFabButton;
6767
// @public
6868
export class MatFabButton extends MatButtonBase {
6969
constructor(...args: unknown[]);
70+
expanded: boolean;
7071
// (undocumented)
7172
extended: boolean;
7273
// (undocumented)
7374
_isFab: boolean;
7475
// (undocumented)
76+
static ngAcceptInputType_expanded: unknown;
77+
// (undocumented)
7578
static ngAcceptInputType_extended: unknown;
7679
// (undocumented)
77-
static ɵcmp: i0.ɵɵComponentDeclaration<MatFabButton, "button[mat-fab], a[mat-fab], button[matFab], a[matFab]", ["matButton", "matAnchor"], { "extended": { "alias": "extended"; "required": false; }; }, {}, never, [".material-icons:not([iconPositionEnd]), mat-icon:not([iconPositionEnd]), [matButtonIcon]:not([iconPositionEnd])", "*", ".material-icons[iconPositionEnd], mat-icon[iconPositionEnd], [matButtonIcon][iconPositionEnd]", "[progressIndicator]"], true, never>;
80+
static ɵcmp: i0.ɵɵComponentDeclaration<MatFabButton, "button[mat-fab], a[mat-fab], button[matFab], a[matFab]", ["matButton", "matAnchor"], { "extended": { "alias": "extended"; "required": false; }; "expanded": { "alias": "expanded"; "required": false; }; }, {}, never, [".material-icons:not([iconPositionEnd]), mat-icon:not([iconPositionEnd]), [matButtonIcon]:not([iconPositionEnd])", "*", ".material-icons[iconPositionEnd], mat-icon[iconPositionEnd], [matButtonIcon][iconPositionEnd]", "[progressIndicator]"], true, never>;
7881
// (undocumented)
7982
static ɵfac: i0.ɵɵFactoryDeclaration<MatFabButton, never>;
8083
}

src/dev-app/button/button-demo.html

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ <h4 class="demo-section-header">Buttons</h4>
1616
Search
1717
<mat-icon iconPositionEnd>check</mat-icon>
1818
</button>
19+
<button matFab extended [expanded]="false">
20+
<mat-icon>search</mat-icon>
21+
Collapsed
22+
</button>
1923
</section>
2024
<section>
2125
@for (appearance of appearances; track $index) {
@@ -135,6 +139,10 @@ <h4 class="demo-section-header">Anchors</h4>
135139
Search
136140
<mat-icon iconPositionEnd>check</mat-icon>
137141
</a>
142+
<a href="//www.google.com" matFab extended [expanded]="false">
143+
<mat-icon>search</mat-icon>
144+
Collapsed
145+
</a>
138146
</section>
139147
<section>
140148
<a
@@ -340,6 +348,21 @@ <h4 class="demo-section-header">FABs</h4>
340348
</button>
341349
</section>
342350

351+
<h4 class="demo-section-header">Extended FABs</h4>
352+
<section>
353+
<p>
354+
<mat-checkbox [(ngModel)]="extendFABExpanded">Expanded</mat-checkbox>
355+
</p>
356+
<button matFab extended [expanded]="extendFABExpanded">
357+
<mat-icon>edit</mat-icon>
358+
Compose
359+
</button>
360+
<button matFab extended [expanded]="extendFABExpanded" color="primary">
361+
<mat-icon>edit</mat-icon>
362+
long long long text
363+
</button>
364+
</section>
365+
343366
<h4 class="demo-section-header">Mini FABs</h4>
344367
<section>
345368
<button matMiniFab>

src/dev-app/button/button-demo.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export class ButtonDemo {
5050
showProgress = false;
5151
clickCounter = 0;
5252
toggleDisable = false;
53+
extendFABExpanded = true;
5354
tooltipText = 'This is a button tooltip!';
5455
disabledInteractive = false;
5556
appearances: MatButtonAppearance[] = ['text', 'elevated', 'outlined', 'filled', 'tonal'];

src/material/button/button.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@ the `extended` attribute, mini FABs do not.
4343
</button>
4444
```
4545

46+
You can dynamically expand or collapse the extended FAB using the `expanded` input. When `expanded` is set to `false`, the text label is hidden and the extended FAB transitions visually to a circular shape, maintaining the current component structure but occupying less screen estate. By default, `expanded` is unconditionally `true` for all extended FABs.
47+
48+
```html
49+
<button matFab extended [expanded]="isFabExpanded">
50+
<mat-icon>edit</mat-icon>
51+
Compose
52+
</button>
53+
```
54+
4655
### Icon positioning
4756

4857
Buttons can contain icons alongside text. By default, icons (`mat-icon`, `.material-icons`, or

src/material/button/button.spec.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,41 @@ describe('MatButton', () => {
197197
fixture.detectChanges();
198198
expect(extendedFabButtonDebugEl.nativeElement.classList).toContain('mat-mdc-extended-fab');
199199
});
200+
201+
it('should default expanded to true', () => {
202+
const fixture = TestBed.createComponent(TestApp);
203+
fixture.detectChanges();
204+
const extendedFabButtonDebugEl = fixture.debugElement.query(By.css('.extended-fab-test'))!;
205+
206+
fixture.componentInstance.extended = true;
207+
fixture.changeDetectorRef.markForCheck();
208+
fixture.detectChanges();
209+
210+
expect(extendedFabButtonDebugEl.nativeElement.classList).toContain(
211+
'mat-mdc-extended-fab-expanded',
212+
);
213+
expect(extendedFabButtonDebugEl.nativeElement.classList).not.toContain(
214+
'mat-mdc-extended-fab-collapsed',
215+
);
216+
});
217+
218+
it('should add mat-mdc-extended-fab-collapsed class when expanded is false', () => {
219+
const fixture = TestBed.createComponent(TestApp);
220+
fixture.detectChanges();
221+
const extendedFabButtonDebugEl = fixture.debugElement.query(By.css('.extended-fab-test'))!;
222+
223+
fixture.componentInstance.extended = true;
224+
fixture.componentInstance.expanded = false;
225+
fixture.changeDetectorRef.markForCheck();
226+
fixture.detectChanges();
227+
228+
expect(extendedFabButtonDebugEl.nativeElement.classList).toContain(
229+
'mat-mdc-extended-fab-collapsed',
230+
);
231+
expect(extendedFabButtonDebugEl.nativeElement.classList).not.toContain(
232+
'mat-mdc-extended-fab-expanded',
233+
);
234+
});
200235
});
201236

202237
// Regular button tests
@@ -533,7 +568,7 @@ describe('MatFabDefaultOptions', () => {
533568
Fab Button
534569
<span progressIndicator>Progress...</span>
535570
</button>
536-
<button mat-fab [extended]="extended" class="extended-fab-test">Extended</button>
571+
<button mat-fab [extended]="extended" [expanded]="expanded" class="extended-fab-test">Extended</button>
537572
<button mat-mini-fab [showProgress]="showProgress">
538573
Mini Fab Button
539574
<span progressIndicator>Progress...</span>
@@ -551,6 +586,7 @@ class TestApp {
551586
buttonColor!: ThemePalette;
552587
tabIndex!: number;
553588
extended = false;
589+
expanded = true;
554590
disabledInteractive = false;
555591
appearance: MatButtonAppearance = 'text';
556592
showProgress = false;

src/material/button/fab.scss

Lines changed: 95 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
1+
@use 'sass:map';
12
@use './button-base';
23
@use '../core/tokens/token-utils';
34
@use '../core/style/private' as style-private;
45
@use '../core/style/vendor-prefixes';
56
@use '../core/focus-indicators/private' as focus-indicators-private;
7+
@use '../core/tokens/m3/md-sys-motion' as m3-motion;
68
@use './m3-fab';
79

10+
$m3-motion-tokens: m3-motion.md-sys-motion-values();
11+
$easing-emphasized: map.get($m3-motion-tokens, easing-emphasized);
12+
$easing-emphasized-decelerate: map.get($m3-motion-tokens, easing-emphasized-decelerate);
13+
$easing-emphasized-accelerate: map.get($m3-motion-tokens, easing-emphasized-accelerate);
14+
$easing-standard: map.get($m3-motion-tokens, easing-standard);
15+
$easing-standard-decelerate: map.get($m3-motion-tokens, easing-standard-decelerate);
16+
$easing-standard-accelerate: map.get($m3-motion-tokens, easing-standard-accelerate);
17+
$easing-legacy: map.get($m3-motion-tokens, easing-legacy);
18+
$easing-legacy-decelerate: map.get($m3-motion-tokens, easing-legacy-decelerate);
19+
$easing-legacy-accelerate: map.get($m3-motion-tokens, easing-legacy-accelerate);
20+
821
$fallbacks: m3-fab.get-tokens();
922

1023
.mat-mdc-fab-base {
@@ -24,8 +37,8 @@ $fallbacks: m3-fab.get-tokens();
2437
-moz-appearance: none;
2538
-webkit-appearance: none;
2639
overflow: visible;
27-
transition: box-shadow 280ms cubic-bezier(0.4, 0, 0.2, 1), opacity 15ms linear 30ms,
28-
transform 270ms 0ms cubic-bezier(0, 0, 0.2, 1);
40+
transition: box-shadow 280ms $easing-legacy, opacity 15ms linear 30ms,
41+
transform 270ms 0ms $easing-legacy-decelerate;
2942
flex-shrink: 0; // Prevent the button from shrinking since it's always supposed to be a circle.
3043

3144
// Due to the shape of the FAB, inheriting the shape looks off. Disable it explicitly.
@@ -75,7 +88,7 @@ $fallbacks: m3-fab.get-tokens();
7588
// mixin will style the icons appropriately.
7689
// stylelint-disable-next-line selector-class-pattern
7790
.mat-icon, .material-icons {
78-
transition: transform 180ms 90ms cubic-bezier(0, 0, 0.2, 1);
91+
transition: transform 180ms 90ms $easing-legacy-decelerate;
7992
fill: currentColor;
8093
will-change: transform;
8194
}
@@ -167,11 +180,8 @@ $fallbacks: m3-fab.get-tokens();
167180
// tokens it doesn't. We add it since it can cause tiny differences in
168181
// screenshot tests and it generally looks better.
169182
@include vendor-prefixes.smooth-font();
170-
padding-left: 20px;
171-
padding-right: 20px;
183+
172184
width: auto;
173-
max-width: 100%;
174-
line-height: normal;
175185
box-shadow: token-utils.slot(fab-extended-container-elevation-shadow, $fallbacks);
176186
height: token-utils.slot(fab-extended-container-height, $fallbacks);
177187
border-radius: token-utils.slot(fab-extended-container-shape, $fallbacks);
@@ -180,6 +190,20 @@ $fallbacks: m3-fab.get-tokens();
180190
font-weight: token-utils.slot(fab-extended-label-text-weight, $fallbacks);
181191
letter-spacing: token-utils.slot(fab-extended-label-text-tracking, $fallbacks);
182192

193+
// Outer container padding: expanded state (asymmetrical layout)
194+
// LTR: 16px at start (close to icon), 20px at end (close to text)
195+
padding-left: 16px;
196+
padding-right: 20px;
197+
transition: padding 270ms $easing-standard,
198+
margin 270ms $easing-standard,
199+
border-radius 270ms $easing-standard;
200+
201+
// RTL: start moves to right (16px), end moves to left (20px)
202+
[dir='rtl'] & {
203+
padding-left: 20px;
204+
padding-right: 16px;
205+
}
206+
183207
@media (hover: hover) {
184208
&:hover {
185209
box-shadow: token-utils.slot(fab-extended-hover-container-elevation-shadow, $fallbacks);
@@ -203,22 +227,74 @@ $fallbacks: m3-fab.get-tokens();
203227

204228
// stylelint-disable selector-class-pattern
205229
// For Extended FAB with text label followed by icon.
206-
// We are checking for the a button class because white this is a FAB it
230+
// We are checking for the a button class because while this is a FAB it
207231
// uses the same template as button.
208-
[dir='rtl'] & .mdc-button__label + .mat-icon,
209-
[dir='rtl'] & .mdc-button__label + .material-icons,
210-
> .mat-icon,
211-
> .material-icons {
212-
margin-left: -8px;
232+
.mdc-button__label {
233+
white-space: nowrap;
234+
opacity: 1;
235+
display: inline-grid;
236+
// Explicitly break the auto constraint, allowing tracks to be squeezed to 0
237+
// to achieve "label width" set to 0.
238+
grid-template-columns: minmax(0, 1fr);
239+
overflow: hidden;
240+
241+
transition: opacity 150ms 100ms linear,
242+
grid-template-columns 270ms $easing-standard;
243+
}
244+
245+
// Icon visually positioned on the LEFT (first icon in LTR, or last icon in RTL)
246+
// No margin on the left, rely on parent container padding to create space.
247+
// Use 12px margin on the right to push text.
248+
[dir='rtl'] & .mdc-button__label + .mat-icon,
249+
[dir='rtl'] & .mdc-button__label + .material-icons,
250+
> .mat-icon,
251+
> .material-icons {
252+
margin-left: 0;
213253
margin-right: 12px;
214-
}
254+
transition: margin 270ms $easing-standard;
255+
}
215256

216-
.mdc-button__label + .mat-icon,
217-
.mdc-button__label + .material-icons,
218-
[dir='rtl'] & > .mat-icon,
219-
[dir='rtl'] & > .material-icons {
257+
// Icon visually positioned on the RIGHT (last icon in LTR, or first icon in RTL)
258+
// 12px margin on the left to push text; no margin on the right,
259+
// rely on parent container padding.
260+
.mdc-button__label + .mat-icon,
261+
.mdc-button__label + .material-icons,
262+
[dir='rtl'] & > .mat-icon,
263+
[dir='rtl'] & > .material-icons {
220264
margin-left: 12px;
221-
margin-right: -8px;
265+
margin-right: 0;
266+
transition: margin 270ms $easing-standard;
267+
}
268+
269+
&.mat-mdc-extended-fab-collapsed {
270+
// Force symmetric 16px padding.
271+
// 16px (spacing) + 24px (icon) + 16px (spacing) = 56px perfect circle.
272+
padding-left: 16px;
273+
padding-right: 16px;
274+
275+
[dir='rtl'] & {
276+
padding-left: 16px;
277+
padding-right: 16px;
278+
}
279+
280+
// Collapse text and make it transparent when collapsed
281+
.mdc-button__label {
282+
grid-template-columns: minmax(0, 0fr);
283+
284+
opacity: 0;
285+
transition: grid-template-columns 270ms $easing-standard,
286+
opacity 100ms 0ms linear;
287+
}
288+
289+
// When collapsed, remove all extra icon margins to allow flexbox to center it perfectly
290+
> .mat-icon,
291+
> .material-icons,
292+
.mdc-button__label + .mat-icon,
293+
.mdc-button__label + .material-icons {
294+
margin-left: 0;
295+
margin-right: 0;
296+
transition: margin 270ms $easing-standard;
297+
}
222298
}
223299
// stylelint-enable selector-class-pattern
224300

src/material/button/fab.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ const defaults: MatFabDefaultOptions = {
6161
'class': 'mdc-fab mat-mdc-fab-base mat-mdc-fab',
6262
'[class.mdc-fab--extended]': 'extended',
6363
'[class.mat-mdc-extended-fab]': 'extended',
64+
'[class.mat-mdc-extended-fab-collapsed]': 'extended && !expanded',
6465
},
6566
exportAs: 'matButton, matAnchor',
6667
encapsulation: ViewEncapsulation.None,
@@ -73,6 +74,9 @@ export class MatFabButton extends MatButtonBase {
7374

7475
@Input({transform: booleanAttribute}) extended: boolean = false;
7576

77+
/** Whether the extended-FAB is currently expanded. Has no effect on non-extended FABs. */
78+
@Input({transform: booleanAttribute}) expanded: boolean = true;
79+
7680
constructor(...args: unknown[]);
7781

7882
constructor() {

0 commit comments

Comments
 (0)