import LocationHandler from "@supersoniks/concorde/core/utils/LocationHandler"; import { html, LitElement, css, CSSResultGroup } from "lit"; import { customElement, property, queryAssignedElements, state, } from "lit/decorators.js"; import { styleMap } from "lit/directives/style-map.js"; import FormElement from "@supersoniks/concorde/core/mixins/FormElement"; import FormCheckable from "@supersoniks/concorde/core/mixins/FormCheckable"; import Subscriber from "@supersoniks/concorde/core/mixins/Subscriber"; import { ifDefined } from "lit/directives/if-defined.js"; import { fontSize, Size, } from "@supersoniks/concorde/core/components/ui/_css/size"; import { PublisherManager } from "@supersoniks/concorde/utils"; import { Route } from "@supersoniks/concorde/core/utils/route"; export type ButtonType = | "default" | "primary" | "neutral" | "warning" | "danger" | "success" | "info"; export type ButtonShape = "default" | "circle" | "square" | "block"; export type ButtonVariant = | "default" | "ghost" | "outline" | "unstyled" | "link"; const tagName = "sonic-button"; /** * Un bouton simple avec deux slots, un nommé préfix et un nomé suffix de manière à pouvoir mettre (par exemple) une icone avant ou après le contenu. * * L'objet et ses slot sont en display flex avec direction / alignement et justifications configurables * * Le bouton est comparable au badge car il possèdent tous les deux les propriétés *type* (primary...), *variant*(outline, ghost), size(xs...)... * * Le bouton possède cependant et notamment une propriété href contrairement à un badge */ @customElement(tagName) export class Button extends FormCheckable(FormElement(Subscriber(LitElement))) { static styles = [ fontSize, css` * { box-sizing: border-box; } :host { --sc-btn-gap: 0.35em; --sc-btn-py: 0.25em; --sc-btn-px: 1.1em; --sc-btn-fs: var(--sc-_fs, 1rem); --sc-btn-ff: var( --sc-btn-font-family, var(--sc-font-family-base, sans-serif) ); --sc-btn-fw: var(--sc-btn-font-weight, 500); --sc-btn-height: var(--sc-form-height, 2.5em); --btn-color: var(--sc-btn-color, var(--sc-base-content, #000)); --btn-bg: var(--sc-btn-bg, var(--sc-base-100, rgba(0, 0, 0, 0.07))); --sc-btn-border-style: solid; --sc-btn-border-width: var(--sc-form-border-width); --sc-btn-border-color: transparent; --btn-outline-bg-hover: var( --sc-btn-outline-bg-hover, var(--sc-base-100, rgba(0, 0, 0, 0.07)) ); --sc-btn-ghost-bg-hover: var(--sc-base-100, rgba(0, 0, 0, 0.07)); --sc-btn-active-color: var(--sc-base, #fff); --sc-btn-hover-filter: brightness(0.98); --sc-btn-active-filter: brightness(0.97); --sc-btn-active-bg: var(--sc-base-content, #000); /* min permet une sécurité si btn-rounded 999px par exemple */ --sc-item-rounded-tr: min( calc(var(--sc-form-height, 2.5em) / 2), var(--sc-btn-rounded) ); --sc-item-rounded-tl: min( calc(var(--sc-form-height, 2.5em) / 2), var(--sc-btn-rounded) ); --sc-item-rounded-bl: min( calc(var(--sc-form-height, 2.5em) / 2), var(--sc-btn-rounded) ); --sc-item-rounded-br: min( calc(var(--sc-form-height, 2.5em) / 2), var(--sc-btn-rounded) ); display: inline-flex; vertical-align: middle; box-sizing: border-box; -webkit-print-color-adjust: exact; } :host a { display: contents; color: unset; } :host button { display: flex; flex: 1; box-sizing: border-box; align-items: center; justify-content: center; font-family: var(--sc-btn-ff); font-weight: var(--sc-btn-fw); font-size: var(--sc-btn-fs); cursor: pointer; text-align: center; line-height: 1.1; border-radius: var(--sc-item-rounded-tl) var(--sc-item-rounded-tr) var(--sc-item-rounded-br) var(--sc-item-rounded-bl); background: var(--btn-bg); color: var(--btn-color); padding-top: var(--sc-btn-py); padding-bottom: var(--sc-btn-py); padding-left: var(--sc-btn-px); padding-right: var(--sc-btn-px); border: var(--sc-btn-border-width) var(--sc-btn-border-style) var(--sc-btn-border-color); min-height: var(--sc-btn-height); } :host button.has-prefix-or-suffix { gap: var(--sc-btn-gap); } :host button:focus-visible, :host button:hover { filter: var(--sc-btn-hover-filter); } :host button:active { filter: var(--sc-btn-active-filter); } /*TYPES*/ :host([type="default"]) button { --btn-color: var(--sc-base-content, #000); --btn-bg: var(--sc-base-100, rgba(0, 0, 0, 0.07)); } :host([type="primary"]) button { --btn-color: var(--sc-primary-content, var(--sc-base, #fff)); --btn-bg: var(--sc-primary, var(--sc-base-content, #000)); } :host([type="warning"]) button { --btn-color: var(--sc-warning-content, var(--sc-base, #fff)); --btn-bg: var(--sc-warning, var(--sc-base-content, #000)); } :host([type="danger"]) button { --btn-color: var(--sc-danger-content, var(--sc-base, #fff)); --btn-bg: var(--sc-danger, var(--sc-base-content, #000)); } :host([type="info"]) button { --btn-color: var(--sc-info-content, var(--sc-base, #fff)); --btn-bg: var(--sc-info, var(--sc-base-content, #000)); } :host([type="success"]) button { --btn-color: var(--sc-success-content, var(--sc-base, #fff)); --btn-bg: var(--sc-success, var(--sc-base-content, #000)); } :host([type="neutral"]) button { --btn-color: var(--sc-base, #fff); --btn-bg: var(--sc-base-content, #000); } :host([type="custom"]) button { --btn-color: var(--sc-btn-custom-color); --btn-bg: var(--sc-btn-custom-bg); } /*UNSTYLED*/ :host([variant="unstyled"]) { display: inline-flex; } :host([variant="unstyled"]) button { all: unset; cursor: pointer; width: 100%; flex: 1; box-sizing: border-box; --sc-btn-height: auto; } /*GESTION DU FOCUS*/ :host(:not([disabled])) button:focus-visible { box-shadow: 0 0 0 0.18rem var(--sc-base-300, rgba(0, 0, 0, 0.18)); border-color: var(--sc-base-300, rgba(0, 0, 0, 0.18)) !important; outline: none; } /*GHOST*/ :host([variant="ghost"][type]) button { color: var(--btn-bg); background: transparent; } :host([variant="ghost"][type="default"]) button { color: var(--btn-color); background: transparent; } /*:host([variant="ghost"]) button:focus-visible,*/ :host([variant="ghost"]) button:hover { background: var(--sc-btn-ghost-bg-hover); filter: none; } :host([active][variant="ghost"]) button { background: var(--sc-btn-ghost-bg-hover); filter: none; } :host([active][variant="ghost"]) button:hover { filter: var(--sc-btn-hover-filter); } /*OUTLINE*/ :host([variant="outline"][type]) button { border-color: var(--btn-bg); color: var(--btn-bg); background: transparent; } :host([variant="outline"][type="default"]) button { border-color: var(--sc-base-content, #000); color: var(--sc-base-content, #000); background: transparent; } /*:host([variant="outline"]) button:focus-visible,*/ :host([variant="outline"]) button:hover { background: var(--btn-outline-bg-hover); } /*OUTLINE*/ :host([variant="link"]:not([size])) { vertical-align: baseline; margin-left: 0.25em; margin-right: 0.25em; } :host([variant="link"]:not([size])) { font-size: inherit; } :host([variant="link"]) button { text-decoration: underline; padding: 0; background: none; border: none; font-size: inherit; min-height: 0; color: inherit; } :host([variant="link"][type]) button { color: var(--btn-bg); } :host([variant="link"][type="default"]) button { color: inherit; } :host([variant="link"]) button:focus-visible, :host([variant="link"]) button:hover { text-decoration: none; } /* Alignement */ :host([align="left"]) button { text-align: left !important; } :host([align="right"]) button { text-align: right; } /*SHAPE*/ :host([shape="circle"]) button { border-radius: 50%; } :host([shape="circle"]) .main-slot { line-height: 1; } :host([shape="circle"]) button, :host([shape="square"]) button { width: var(--sc-btn-height); height: var(--sc-btn-height); /*overflow: hidden;*/ /* fix bug #42622 */ padding: 0; align-items: center; justify-content: 0; text-align: center !important; } :host([shape="block"]), :host([shape="block"]) button { width: 100%; } :host([disabled]) { opacity: 0.3; pointer-events: none; user-select: none; } /*ACTIVE*/ :host([active]:not([variant="ghost"]):not([variant="unstyled"])) button { background: var(--sc-btn-active-bg); color: var(--sc-btn-active-color); border-color: var(--sc-btn-active-bg); } .main-slot { flex-grow: 1; min-width: 0; display: block; } :host([minWidth]:not([shape="block"])) .main-slot { flex-grow: 0; } slot[name="suffix"], slot[name="prefix"] { flex-shrink: 0; min-width: 0; } /*ALIGNEMENT DES ICONES permet de tous les avoir alignés dans un menu */ ::slotted(sonic-icon) { min-width: 1em; text-align: center; } /*BOUTON Avec icone seulement*/ :host([icon]) ::slotted(:only-child), :host([icon]) ::slotted(sonic-icon) { font-size: 1.2em; vertical-align: middle; } /*Tooltip ne joue pas sur le layout*/ sonic-tooltip { display: contents; } /*OUTLINE*/ :host(:not([active])) ::slotted([swap="on"]) { display: none !important; } :host([active]) ::slotted([swap="off"]) { display: none !important; } /*Loading*/ :host([loading]) { pointer-events: none; position: relative; } :host([loading]) slot { opacity: 0 !important; pointer-events: none; } /*Loading*/ :host([loading]) .loader { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: flex; align-items: center; justify-content: center; line-height: 0; height: var(--sc-btn-ff); width: var(--sc-btn-ff); animation: rotation 2s infinite linear; } @keyframes rotation { from { transform-origin: 50% 50%; transform: translate(-50%, -50%) rotate(0deg); } to { transform-origin: 50% 50%; transform: translate(-50%, -50%) rotate(359deg); } } ` as CSSResultGroup, ]; /** * Le type change surtout la couleur composant */ @property({ type: String, reflect: true }) type: ButtonType = "default"; /** * Le composant par defaut sans se paramètre à forte affordance * * gost : composant super léger visuellement * * outline : composant légé avec une bordure */ @property({ type: String, reflect: true }) variant: ButtonVariant = "default"; /** * Taille du composant, implique notamment des modifications de typo et de marge interne */ @property({ type: String, reflect: true }) size?: Size; /** * Forme du bouton, permet de le forcer en carré ou cercle */ @property({ type: String, reflect: true }) shape: | "default" | "circle" | "square" | "block" = "default"; /** * Propriété de direction *flex* appliquée à l'élément contenant les *slots* */ @property({ type: String }) direction = "row"; /** * Propriété align-items de *flex* appliquée à l'élément contenant les *slots* */ @property({ type: String, reflect: true }) alignItems = "center"; /** * Propriété justify-content de *flex* appliquée à l'élément contenant les *slots* */ @property({ type: String }) justify = "center"; /** * Propriété text-align du bouton */ @property({ type: String, reflect: true }) align?: | "right" | "left" | "center"; /** * Propriété min-width du bouton */ @property({ type: String }) minWidth = "0"; /** * Si un bouton n'a qu'une icone pour contenu, agrandi l'icone pour des raisons d'équilibre */ @property({ type: Boolean, reflect: true }) icon = false; @property({ type: String }) download: string | null = null; /** * mode d'activation du bouton : * - strict : l'url courante match exactement avec le href du bouton * - partial : l'url courante match à gauche avec le href du bouton * - strict-path-only : l'url courante match exactement avec le path du bouton sans les query params * - disabled : aucune activation / désactivation */ @property({ type: String }) autoActive: "strict" | "partial" | "disabled" = "partial"; /** * Laisse apparaitre un loader en remplacement du contenu du bouton. * Désactive également le clic sur le bouton */ @property({ type: Boolean, reflect: true }) loading = false; @state() hasPrefix = false; @state() hasSuffix = false; @queryAssignedElements({ flatten: true, slot: "prefix" }) prefixes!: HTMLElement[]; @queryAssignedElements({ flatten: true, slot: "suffix" }) suffixes!: HTMLElement[]; /** * target */ @property({ type: String }) target?: "_self" | "_blank" | "_parent" | "_top"; /** * L'url */ private _href: Route | string = ""; @property({ type: String }) set href(value: Route | string) { this._href = value; const str = this._href.toString(); if (str && str.indexOf("http") != 0) { LocationHandler.onChange(this); } else LocationHandler.offChange(this); this.requestUpdate(); } get href(): Route | string { return this._href; } @property({ type: String }) goBack: Route | string | null = null; /** * Si présent on passe en mode pushstate */ @property({ type: Boolean }) pushState = false; @property({ type: Boolean, reflect: true }) active = false; @property({ type: Boolean, reflect: true }) autoRepeat = false; @property({ type: String, attribute: "data-aria-controls" }) ariaControls?: string; //@todo : se documenter sur // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/attachInternals // https://github.com/lit/lit/blob/1b00c07a58707f45ad066809cd79f878fc3590d2/packages/labs/ssr-dom-shim/src/lib/element-internals.ts#L39 @property({ type: Boolean, attribute: "data-aria-expanded" }) sonicAriaExpanded?: boolean; handleNavigation(e: Event) { e.preventDefault(); LocationHandler.changeFromComponent(this); } handleChange(e?: Event): void { if (e?.type == "click" && this.autoRepeat) return; super.handleChange(); if (this.pushState || this.goBack !== null) { e?.preventDefault(); e?.stopPropagation(); LocationHandler.changeFromComponent(this); } if (this.hasAttribute("reset")) { /** * On on veut pouvoir rest un autre form que celui qui contient le bouton */ const resetDataProvider = this.getAttribute("reset"); const formPublisher = resetDataProvider ? PublisherManager.get(resetDataProvider) : this.getFormPublisher(); if (formPublisher) formPublisher.set({}); } } pointerDownTime = 0; lastRepeatTime = 0; isRepeating = false; handleRepeatStart(e: Event) { if (this.autoRepeat) { this.handleChange(e); this.pointerDownTime = Date.now(); this.isRepeating = true; this.repeat(); } window.addEventListener("pointerup", this.handleRepeatend); window.addEventListener("blur", this.handleRepeatend); } handleRepeatend = () => { window.removeEventListener("pointerup", this.handleRepeatend); window.removeEventListener("blur", this.handleRepeatend); if (this.autoRepeat) { this.isRepeating = false; } }; repeat() { if (!this.isRepeating) return; if (this.hasAttribute("disabled")) { this.isRepeating = false; return; } window.requestAnimationFrame(this.repeat.bind(this)); if (Date.now() - this.pointerDownTime < 500) return; if (Date.now() - this.lastRepeatTime < 100) return; this.handleChange(); this.lastRepeatTime = Date.now(); } connectedCallback() { super.connectedCallback(); } setCheckedValue(checked: true | null): void { if (this.name) { if (checked) this.setAttribute("active", "true"); else this.removeAttribute("active"); if (checked == this._checked) return; super.setCheckedValue(checked); } } disconnectedCallback(): void { LocationHandler.offChange(this); super.disconnectedCallback(); } @state() location = ""; willUpdate(changedProperties: Map) { if (changedProperties.has("href") || changedProperties.has("autoActive")) { LocationHandler.updateComponentActiveState(this); } if (changedProperties.has("location")) { LocationHandler.updateComponentActiveState(this); } } render() { const btnStyles = { flexDirection: this.direction, alignItems: this.alignItems, justifyContent: this.justify, align: this.align, minWidth: this.minWidth, }; const btn = html` `; return this.href ? html`${btn}` : html`${btn}`; } onSlotChange() { this.hasPrefix = !!this.prefixes?.length; this.hasSuffix = !!this.suffixes?.length; } }