Skip to content

Commit ba8c36e

Browse files
committed
feat(components): icon button component
1 parent a480109 commit ba8c36e

File tree

1 file changed

+228
-11
lines changed

1 file changed

+228
-11
lines changed
Lines changed: 228 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,236 @@
1-
import {html} from 'lit/html.js';
1+
import {GecutDirective} from '@gecut/lit-helper/directives/directive.js';
2+
import {directive, type PartInfo} from 'lit/directive.js';
3+
import {classMap} from 'lit/directives/class-map.js';
4+
import {ifDefined} from 'lit/directives/if-defined.js';
5+
import {html, noChange, nothing} from 'lit/html.js';
6+
import {literal, html as staticHtml} from 'lit/static-html.js';
27

38
import {icon, type IconContent} from '../icon/icon.js';
9+
import {gecutEFO} from '../internal/events-handler.js';
410

11+
import type {EventsObject} from '../internal/events-handler.js';
12+
import type {ClassInfo} from 'lit/directives/class-map.js';
13+
import type {StaticValue} from 'lit/static-html.js';
14+
15+
/**
16+
* Represents the content of an icon button.
17+
*
18+
* @interface IconButtonContent
19+
* @extends IconContent
20+
*/
521
export interface IconButtonContent extends IconContent {
22+
/**
23+
* The type of the icon button.
24+
*
25+
* A 'normal' button has a transparent background.
26+
* A 'filled' button has a solid background color.
27+
* A 'filledTonal' button has a tonal background color.
28+
* An 'outlined' button has a bordered appearance.
29+
*
30+
* @type {'normal' | 'filled' | 'filledTonal' | 'outlined'}
31+
* @default 'normal'
32+
*/
33+
type?: 'normal' | 'filled' | 'filledTonal' | 'outlined';
34+
35+
/**
36+
* The URL that the button links to.
37+
*
38+
* When provided, the button will be rendered as an `<a>` tag instead of a `<button>`.
39+
*
40+
* @type {string}
41+
*/
42+
href?: string;
43+
44+
/**
45+
* The target attribute for the button's link.
46+
*
47+
* Specifies where to open the linked document.
48+
*
49+
* @type {'_blank' | '_parent' | '_self' | '_top'}
50+
*/
51+
target?: '_blank' | '_parent' | '_self' | '_top';
52+
53+
/**
54+
* An object containing event handlers for the button.
55+
*
56+
* @type {EventsObject}
57+
*/
58+
events?: EventsObject;
59+
60+
/**
61+
* An alternative icon to display when the button is in a selected state.
62+
*
63+
* @type {IconContent}
64+
*/
65+
selectedIcon?: IconContent;
66+
67+
/**
68+
* The name of the button, used for form submissions.
69+
*
70+
* @type {string}
71+
*/
72+
name?: string;
73+
74+
/**
75+
* The title of the button, shown as a tooltip.
76+
*
77+
* @type {string}
78+
*/
79+
title?: string;
80+
81+
/**
82+
* An icon to display as a loader while the button is in a loading state.
83+
*
84+
* @type {IconContent}
85+
*/
86+
loader?: IconContent;
87+
88+
/**
89+
* Whether the button is disabled and cannot be clicked.
90+
*
91+
* @type {boolean}
92+
*/
693
disabled?: boolean;
794

8-
onClick(event: MouseEvent): void;
95+
/**
96+
* Whether the button is in a loading state.
97+
*
98+
* @type {boolean}
99+
*/
100+
loading?: boolean;
101+
102+
/**
103+
* Whether the button is a toggle button.
104+
*
105+
* @type {boolean}
106+
*/
107+
toggle?: boolean;
108+
109+
/**
110+
* Whether the toggle button is in a checked state.
111+
*
112+
* @type {boolean}
113+
*/
114+
checked?: boolean;
115+
}
116+
117+
export class IconButtonDirective extends GecutDirective {
118+
constructor(partInfo: PartInfo) {
119+
super(partInfo, 'gecut-icon-button');
120+
}
121+
protected content?: IconButtonContent;
122+
protected type: 'link' | 'button' | 'toggle' = 'button';
123+
124+
render(content?: IconButtonContent): unknown {
125+
this.log.methodArgs?.('render', content);
126+
127+
if (content === undefined) return noChange;
128+
129+
this.content = content;
130+
131+
if (this.content.href) this.type = 'link';
132+
else if (this.content.toggle) this.type = 'toggle';
133+
134+
return this.renderButton();
135+
}
136+
137+
protected renderButton() {
138+
if (!this.content) return nothing;
139+
140+
this.log.method?.('renderButton');
141+
142+
let tag: StaticValue;
143+
144+
switch (this.type) {
145+
case 'link':
146+
tag = literal`a`;
147+
break;
148+
case 'button':
149+
tag = literal`button`;
150+
break;
151+
case 'toggle':
152+
tag = literal`label`;
153+
break;
154+
}
155+
156+
return staticHtml`
157+
<${tag}
158+
class=${classMap(this.getRenderClasses())}
159+
role=${this.type === 'toggle' ? 'checkbox' : 'button'}
160+
href=${ifDefined(this.content.href)}
161+
target=${ifDefined(this.content.target)}
162+
title=${ifDefined(this.content.title)}
163+
tabindex="${this.content.disabled ? -1 : 0}"
164+
?disabled=${this.content.disabled ?? false}
165+
?loading=${this.content.loading ?? false}
166+
@keypress=${(event: KeyboardEvent) => {
167+
if (this.type !== 'toggle') return;
168+
169+
if (event.key === 'Enter' || event.key === ' ') {
170+
const target = (event.currentTarget || event.target) as HTMLLabelElement | null;
171+
const input = target?.querySelector<HTMLInputElement>('input[type="checkbox"]');
172+
173+
if (input) {
174+
input.checked = !input.checked;
175+
}
176+
}
177+
}}
178+
${gecutEFO(this.content.events)}
179+
>${this.renderCheckbox()}${this.renderLoader()}${this.renderIcon()}</${tag}>
180+
`;
181+
}
182+
protected renderCheckbox(): unknown {
183+
if (!this.content || this.type !== 'toggle') return nothing;
184+
185+
this.log.method?.('renderCheckbox');
186+
187+
return html`<input type="checkbox" name=${ifDefined(this.content.name)} ?checked=${this.content.checked} hidden />`;
188+
}
189+
protected renderIcon(): unknown {
190+
if (!this.content) return nothing;
191+
192+
this.log.method?.('renderIcon');
193+
194+
if (!this.content.selectedIcon) return html`<div class="gecut-icon-button-icon">${icon(this.content)}</div>`;
195+
196+
return html`
197+
<div class="gecut-icon-button-icon gecut-icon-button-unselected-icon">${icon(this.content)}</div>
198+
199+
<div class="gecut-icon-button-icon gecut-icon-button-selected-icon">${icon(this.content!.selectedIcon!)}</div>
200+
`;
201+
}
202+
protected renderLoader(): unknown {
203+
if (!this.content) return nothing;
204+
205+
this.log.method?.('renderLoader');
206+
207+
return html`
208+
<div class="gecut-icon-button-loader">
209+
${icon(
210+
this.content.loader ?? {
211+
// eslint-disable-next-line max-len
212+
svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g stroke="currentColor"><circle cx="12" cy="12" r="9.5" fill="none" stroke-linecap="round" stroke-width="2.5"><animate attributeName="stroke-dasharray" calcMode="spline" dur="1.5s" keySplines="0.42,0,0.58,1;0.42,0,0.58,1;0.42,0,0.58,1" keyTimes="0;0.475;0.95;1" repeatCount="indefinite" values="0 150;42 150;42 150;42 150"/><animate attributeName="stroke-dashoffset" calcMode="spline" dur="1.5s" keySplines="0.42,0,0.58,1;0.42,0,0.58,1;0.42,0,0.58,1" keyTimes="0;0.475;0.95;1" repeatCount="indefinite" values="0;-16;-59;-59"/></circle><animateTransform attributeName="transform" dur="2s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/></g></svg>',
213+
},
214+
)}
215+
</div>
216+
`;
217+
}
218+
219+
protected override getRenderClasses(): ClassInfo {
220+
if (!this.content) return super.getRenderClasses();
221+
222+
this.content.type ??= 'normal';
223+
224+
return {
225+
...super.getRenderClasses(),
226+
227+
toggle: this.type === 'toggle',
228+
filled: this.content.type === 'filled',
229+
'filled-tonal': this.content.type === 'filledTonal',
230+
outlined: this.content.type === 'outlined',
231+
normal: this.content.type === 'normal',
232+
};
233+
}
9234
}
10235

11-
export const iconButton = (content: IconButtonContent) => html`
12-
<button
13-
@click=${content.onClick}
14-
class="text-onSurface focus-ring m-1 flex h-10 w-10 items-center justify-center
15-
rounded-full hover:stateHover-onSurface active:stateActive-onSurface"
16-
>
17-
${icon(content)}
18-
</button>
19-
`;
236+
export const gecutIconButton = directive(IconButtonDirective);

0 commit comments

Comments
 (0)