|
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'; |
2 | 7 |
|
3 | 8 | import {icon, type IconContent} from '../icon/icon.js'; |
| 9 | +import {gecutEFO} from '../internal/events-handler.js'; |
4 | 10 |
|
| 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 | + */ |
5 | 21 | 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 | + */ |
6 | 93 | disabled?: boolean; |
7 | 94 |
|
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 | + } |
9 | 234 | } |
10 | 235 |
|
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