@mfp-design-system/button 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,22 +52,33 @@ For custom styling, the inner `<button>` is exposed as a CSS shadow part:
52
52
 
53
53
  ```css
54
54
  mfp-button::part(button) {
55
- border-radius: 999px;
55
+ border-radius: 999px;
56
56
  }
57
57
  ```
58
58
 
59
59
  ## Framework notes
60
60
 
61
61
  - **Vue 3 / Nuxt**: tell the template compiler that `mfp-` tags are custom elements:
62
- ```ts
63
- // vite.config.ts or nuxt.config.ts
64
- compilerOptions: {
65
- isCustomElement: (tag) => tag.startsWith('mfp-'),
66
- }
67
- ```
62
+ ```ts
63
+ // vite.config.ts or nuxt.config.ts
64
+ compilerOptions: {
65
+ isCustomElement: (tag) => tag.startsWith('mfp-'),
66
+ }
67
+ ```
68
68
  - **Angular**: add `CUSTOM_ELEMENTS_SCHEMA` to any module/standalone component that uses `<mfp-button>`.
69
69
  - **React**: works natively in React 19+; for older React, listen for the `click` event via a ref.
70
70
 
71
- ## Known limitations
71
+ ## Forms
72
72
 
73
- - The button does not yet participate in HTML form submission (no `ElementInternals` form-association). It works fine for click handlers; if you need it inside a `<form>` with submit semantics, wrap submission in your own handler for now. This is on the roadmap.
73
+ `<mfp-button>` is form-associated via `ElementInternals`. Inside a `<form>`:
74
+
75
+ - `type="submit"` triggers `form.requestSubmit()` (full native flow including validation and the `submit` event)
76
+ - `type="reset"` triggers `form.reset()`
77
+ - The `form` getter returns the associated `<form>` element if any
78
+
79
+ ```html
80
+ <form @submit.prevent="onSubmit">
81
+ <mfp-input name="email" type="email" required label="Email"></mfp-input>
82
+ <mfp-button type="submit" variant="primary">Sign up</mfp-button>
83
+ </form>
84
+ ```
package/dist/button.d.ts CHANGED
@@ -3,12 +3,18 @@ export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
3
3
  export type ButtonSize = 'sm' | 'md' | 'lg';
4
4
  export type ButtonType = 'button' | 'submit' | 'reset';
5
5
  export declare class MfpButton extends LitElement {
6
+ static formAssociated: boolean;
7
+ private _internals;
8
+ constructor();
9
+ /** The associated <form>, if any. */
10
+ get form(): HTMLFormElement | null;
6
11
  static styles: import("lit").CSSResult;
7
12
  variant: ButtonVariant;
8
13
  size: ButtonSize;
9
14
  disabled: boolean;
10
15
  loading: boolean;
11
16
  type: ButtonType;
17
+ private _onClick;
12
18
  render(): import("lit").TemplateResult<1>;
13
19
  }
14
20
  declare global {
@@ -1 +1 @@
1
- {"version":3,"file":"button.d.ts","sourceRoot":"","sources":["../src/button.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAa,MAAM,KAAK,CAAC;AAG5C,MAAM,MAAM,aAAa,GAAG,SAAS,GAAG,WAAW,GAAG,QAAQ,GAAG,OAAO,CAAC;AACzE,MAAM,MAAM,UAAU,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAC5C,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEvD,qBACa,SAAU,SAAQ,UAAU;IACvC,OAAgB,MAAM,0BAqHpB;IAGF,OAAO,EAAE,aAAa,CAAa;IAGnC,IAAI,EAAE,UAAU,CAAQ;IAGxB,QAAQ,UAAS;IAGjB,OAAO,UAAS;IAGhB,IAAI,EAAE,UAAU,CAAY;IAEnB,MAAM;CAchB;AAED,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,qBAAqB;QAC7B,YAAY,EAAE,SAAS,CAAC;KACzB;CACF"}
1
+ {"version":3,"file":"button.d.ts","sourceRoot":"","sources":["../src/button.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAa,MAAM,KAAK,CAAC;AAG5C,MAAM,MAAM,aAAa,GAAG,SAAS,GAAG,WAAW,GAAG,QAAQ,GAAG,OAAO,CAAC;AACzE,MAAM,MAAM,UAAU,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAC5C,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEvD,qBACa,SAAU,SAAQ,UAAU;IACrC,MAAM,CAAC,cAAc,UAAQ;IAE7B,OAAO,CAAC,UAAU,CAAmB;;IAOrC,qCAAqC;IACrC,IAAI,IAAI,IAAI,eAAe,GAAG,IAAI,CAEjC;IAED,OAAgB,MAAM,0BAqHpB;IAGF,OAAO,EAAE,aAAa,CAAa;IAGnC,IAAI,EAAE,UAAU,CAAQ;IAGxB,QAAQ,UAAS;IAGjB,OAAO,UAAS;IAGhB,IAAI,EAAE,UAAU,CAAY;IAE5B,OAAO,CAAC,QAAQ,CAOd;IAEO,MAAM;CAkBlB;AAED,OAAO,CAAC,MAAM,CAAC;IACX,UAAU,qBAAqB;QAC3B,YAAY,EAAE,SAAS,CAAC;KAC3B;CACJ"}
package/dist/button.js CHANGED
@@ -7,145 +7,165 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
7
7
  import { LitElement, html, css } from 'lit';
8
8
  import { customElement, property } from 'lit/decorators.js';
9
9
  let MfpButton = class MfpButton extends LitElement {
10
+ static { this.formAssociated = true; }
10
11
  constructor() {
11
- super(...arguments);
12
+ super();
12
13
  this.variant = 'primary';
13
14
  this.size = 'md';
14
15
  this.disabled = false;
15
16
  this.loading = false;
16
17
  this.type = 'button';
18
+ this._onClick = () => {
19
+ if (this.disabled || this.loading)
20
+ return;
21
+ if (this.type === 'submit') {
22
+ this.form?.requestSubmit();
23
+ }
24
+ else if (this.type === 'reset') {
25
+ this.form?.reset();
26
+ }
27
+ };
28
+ this._internals = this.attachInternals();
29
+ }
30
+ /** The associated <form>, if any. */
31
+ get form() {
32
+ return this._internals.form;
17
33
  }
18
34
  static { this.styles = css `
19
- :host {
20
- display: inline-block;
21
- }
35
+ :host {
36
+ display: inline-block;
37
+ }
22
38
 
23
- button {
24
- font-family: var(--font-family-sans, system-ui, -apple-system, sans-serif);
25
- font-weight: var(--font-weight-medium, 500);
26
- line-height: var(--font-line-height-tight, 1.2);
27
- border: 1px solid transparent;
28
- border-radius: var(--size-radius-md, 8px);
29
- cursor: pointer;
30
- display: inline-flex;
31
- align-items: center;
32
- justify-content: center;
33
- gap: var(--size-spacing-2, 8px);
34
- white-space: nowrap;
35
- user-select: none;
36
- transition:
37
- background var(--motion-duration-fast, 150ms) var(--motion-easing-standard, ease),
38
- border-color var(--motion-duration-fast, 150ms) var(--motion-easing-standard, ease),
39
- color var(--motion-duration-fast, 150ms) var(--motion-easing-standard, ease),
40
- box-shadow var(--motion-duration-fast, 150ms) var(--motion-easing-standard, ease);
41
- }
39
+ button {
40
+ font-family: var(--font-family-sans, system-ui, -apple-system, sans-serif);
41
+ font-weight: var(--font-weight-medium, 500);
42
+ line-height: var(--font-line-height-tight, 1.2);
43
+ border: 1px solid transparent;
44
+ border-radius: var(--size-radius-md, 8px);
45
+ cursor: pointer;
46
+ display: inline-flex;
47
+ align-items: center;
48
+ justify-content: center;
49
+ gap: var(--size-spacing-2, 8px);
50
+ white-space: nowrap;
51
+ user-select: none;
52
+ transition:
53
+ background var(--motion-duration-fast, 150ms) var(--motion-easing-standard, ease),
54
+ border-color var(--motion-duration-fast, 150ms) var(--motion-easing-standard, ease),
55
+ color var(--motion-duration-fast, 150ms) var(--motion-easing-standard, ease),
56
+ box-shadow var(--motion-duration-fast, 150ms) var(--motion-easing-standard, ease);
57
+ }
42
58
 
43
- button:focus-visible {
44
- outline: 2px solid var(--color-status-info-solid, #2563eb);
45
- outline-offset: 2px;
46
- }
59
+ button:focus-visible {
60
+ outline: 2px solid var(--color-brand-primary, #2563eb);
61
+ outline-offset: 2px;
62
+ }
47
63
 
48
- button:disabled {
49
- cursor: not-allowed;
50
- opacity: 0.5;
51
- }
64
+ button:disabled {
65
+ cursor: not-allowed;
66
+ opacity: 0.5;
67
+ }
52
68
 
53
- /* Sizes — fall back to medium when no [size] attribute is set */
54
- :host(:not([size])) button,
55
- :host([size='md']) button {
56
- padding: var(--size-spacing-2, 8px) var(--size-spacing-4, 16px);
57
- font-size: var(--font-size-base, 16px);
58
- min-height: 40px;
59
- }
60
- :host([size='sm']) button {
61
- padding: var(--size-spacing-1, 4px) var(--size-spacing-3, 12px);
62
- font-size: var(--font-size-sm, 14px);
63
- min-height: 32px;
64
- }
65
- :host([size='lg']) button {
66
- padding: var(--size-spacing-3, 12px) var(--size-spacing-5, 20px);
67
- font-size: var(--font-size-lg, 18px);
68
- min-height: 48px;
69
- }
69
+ /* Sizes — fall back to medium when no [size] attribute is set */
70
+ :host(:not([size])) button,
71
+ :host([size='md']) button {
72
+ padding: var(--size-spacing-2, 8px) var(--size-spacing-4, 16px);
73
+ font-size: var(--font-size-base, 16px);
74
+ min-height: 40px;
75
+ }
76
+ :host([size='sm']) button {
77
+ padding: var(--size-spacing-1, 4px) var(--size-spacing-3, 12px);
78
+ font-size: var(--font-size-sm, 14px);
79
+ min-height: 32px;
80
+ }
81
+ :host([size='lg']) button {
82
+ padding: var(--size-spacing-3, 12px) var(--size-spacing-5, 20px);
83
+ font-size: var(--font-size-lg, 18px);
84
+ min-height: 48px;
85
+ }
70
86
 
71
- /* Variants — fall back to primary when no [variant] attribute is set */
72
- :host(:not([variant])) button,
73
- :host([variant='primary']) button {
74
- background: var(--color-status-info-solid, #2563eb);
75
- color: var(--color-neutral-0, #ffffff);
76
- }
77
- :host(:not([variant])) button:hover:not(:disabled),
78
- :host([variant='primary']) button:hover:not(:disabled) {
79
- background: var(--color-status-info-fg, #1e40af);
80
- box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.08));
81
- }
87
+ /* Variants — fall back to primary when no [variant] attribute is set */
88
+ :host(:not([variant])) button,
89
+ :host([variant='primary']) button {
90
+ background: var(--color-brand-primary, #2563eb);
91
+ color: var(--color-brand-primary-fg, #ffffff);
92
+ }
93
+ :host(:not([variant])) button:hover:not(:disabled),
94
+ :host([variant='primary']) button:hover:not(:disabled) {
95
+ background: var(--color-brand-primary-hover, #1d4ed8);
96
+ box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.08));
97
+ }
82
98
 
83
- :host([variant='secondary']) button {
84
- background: var(--color-neutral-0, #ffffff);
85
- color: var(--color-text-default, #111827);
86
- border-color: var(--color-border-default, #e5e7eb);
87
- }
88
- :host([variant='secondary']) button:hover:not(:disabled) {
89
- background: var(--color-background-subtle, #f9fafb);
90
- border-color: var(--color-border-strong, #9ca3af);
91
- }
99
+ :host([variant='secondary']) button {
100
+ background: var(--color-neutral-0, #ffffff);
101
+ color: var(--color-text-default, #111827);
102
+ border-color: var(--color-border-default, #e5e7eb);
103
+ }
104
+ :host([variant='secondary']) button:hover:not(:disabled) {
105
+ background: var(--color-background-subtle, #f9fafb);
106
+ border-color: var(--color-border-strong, #9ca3af);
107
+ }
92
108
 
93
- :host([variant='danger']) button {
94
- background: var(--color-status-error-solid, #dc2626);
95
- color: var(--color-neutral-0, #ffffff);
96
- }
97
- :host([variant='danger']) button:hover:not(:disabled) {
98
- background: var(--color-status-error-fg, #991b1b);
99
- box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.08));
100
- }
109
+ :host([variant='danger']) button {
110
+ background: var(--color-status-error-solid, #dc2626);
111
+ color: var(--color-neutral-0, #ffffff);
112
+ }
113
+ :host([variant='danger']) button:hover:not(:disabled) {
114
+ background: var(--color-status-error-fg, #991b1b);
115
+ box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.08));
116
+ }
101
117
 
102
- :host([variant='ghost']) button {
103
- background: transparent;
104
- color: var(--color-text-default, #111827);
105
- }
106
- :host([variant='ghost']) button:hover:not(:disabled) {
107
- background: var(--color-background-muted, #f3f4f6);
108
- }
118
+ :host([variant='ghost']) button {
119
+ background: transparent;
120
+ color: var(--color-text-default, #111827);
121
+ }
122
+ :host([variant='ghost']) button:hover:not(:disabled) {
123
+ background: var(--color-background-muted, #f3f4f6);
124
+ }
109
125
 
110
- /* Loading spinner — sized relative to current font-size */
111
- .spinner {
112
- width: 1em;
113
- height: 1em;
114
- border: 2px solid currentColor;
115
- border-top-color: transparent;
116
- border-radius: 50%;
117
- animation: mfp-button-spin 0.6s linear infinite;
118
- flex: none;
119
- }
126
+ /* Loading spinner — sized relative to current font-size */
127
+ .spinner {
128
+ width: 1em;
129
+ height: 1em;
130
+ border: 2px solid currentColor;
131
+ border-top-color: transparent;
132
+ border-radius: 50%;
133
+ animation: mfp-button-spin 0.6s linear infinite;
134
+ flex: none;
135
+ }
120
136
 
121
- @keyframes mfp-button-spin {
122
- to {
123
- transform: rotate(360deg);
124
- }
125
- }
137
+ @keyframes mfp-button-spin {
138
+ to {
139
+ transform: rotate(360deg);
140
+ }
141
+ }
126
142
 
127
- @media (prefers-reduced-motion: reduce) {
128
- button {
129
- transition: none;
130
- }
131
- .spinner {
132
- animation-duration: 1.5s;
133
- }
134
- }
135
- `; }
143
+ @media (prefers-reduced-motion: reduce) {
144
+ button {
145
+ transition: none;
146
+ }
147
+ .spinner {
148
+ animation-duration: 1.5s;
149
+ }
150
+ }
151
+ `; }
136
152
  render() {
137
153
  const isInactive = this.disabled || this.loading;
154
+ // The inner button is always type="button" so it can't trigger native
155
+ // form behavior from inside shadow DOM. The host handles submit/reset
156
+ // via the click handler above and ElementInternals.form.
138
157
  return html `
139
- <button
140
- type=${this.type}
141
- ?disabled=${isInactive}
142
- aria-busy=${this.loading ? 'true' : 'false'}
143
- part="button"
144
- >
145
- ${this.loading ? html `<span class="spinner" aria-hidden="true"></span>` : ''}
146
- <slot></slot>
147
- </button>
148
- `;
158
+ <button
159
+ type="button"
160
+ ?disabled=${isInactive}
161
+ aria-busy=${this.loading ? 'true' : 'false'}
162
+ part="button"
163
+ @click=${this._onClick}
164
+ >
165
+ ${this.loading ? html `<span class="spinner" aria-hidden="true"></span>` : ''}
166
+ <slot></slot>
167
+ </button>
168
+ `;
149
169
  }
150
170
  };
151
171
  __decorate([
@@ -1 +1 @@
1
- {"version":3,"file":"button.js","sourceRoot":"","sources":["../src/button.ts"],"names":[],"mappings":";;;;;;AAAA,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,GAAG,EAAE,MAAM,KAAK,CAAC;AAC5C,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAOrD,IAAM,SAAS,GAAf,MAAM,SAAU,SAAQ,UAAU;IAAlC;;QAyHL,YAAO,GAAkB,SAAS,CAAC;QAGnC,SAAI,GAAe,IAAI,CAAC;QAGxB,aAAQ,GAAG,KAAK,CAAC;QAGjB,YAAO,GAAG,KAAK,CAAC;QAGhB,SAAI,GAAe,QAAQ,CAAC;IAgB9B,CAAC;aApJiB,WAAM,GAAG,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqH3B,AArHqB,CAqHpB;IAiBO,MAAM;QACb,MAAM,UAAU,GAAG,IAAI,CAAC,QAAQ,IAAI,IAAI,CAAC,OAAO,CAAC;QACjD,OAAO,IAAI,CAAA;;eAEA,IAAI,CAAC,IAAI;oBACJ,UAAU;oBACV,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO;;;UAGzC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAA,kDAAkD,CAAC,CAAC,CAAC,EAAE;;;KAG/E,CAAC;IACJ,CAAC;;AA3BD;IADC,QAAQ,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;0CACO;AAGnC;IADC,QAAQ,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;uCACJ;AAGxB;IADC,QAAQ,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;2CAC1B;AAGjB;IADC,QAAQ,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;0CAC3B;AAGhB;IADC,QAAQ,EAAE;uCACiB;AArIjB,SAAS;IADrB,aAAa,CAAC,YAAY,CAAC;GACf,SAAS,CAqJrB"}
1
+ {"version":3,"file":"button.js","sourceRoot":"","sources":["../src/button.ts"],"names":[],"mappings":";;;;;;AAAA,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,GAAG,EAAE,MAAM,KAAK,CAAC;AAC5C,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAOrD,IAAM,SAAS,GAAf,MAAM,SAAU,SAAQ,UAAU;aAC9B,mBAAc,GAAG,IAAI,AAAP,CAAQ;IAI7B;QACI,KAAK,EAAE,CAAC;QAiIZ,YAAO,GAAkB,SAAS,CAAC;QAGnC,SAAI,GAAe,IAAI,CAAC;QAGxB,aAAQ,GAAG,KAAK,CAAC;QAGjB,YAAO,GAAG,KAAK,CAAC;QAGhB,SAAI,GAAe,QAAQ,CAAC;QAEpB,aAAQ,GAAG,GAAG,EAAE;YACpB,IAAI,IAAI,CAAC,QAAQ,IAAI,IAAI,CAAC,OAAO;gBAAE,OAAO;YAC1C,IAAI,IAAI,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;gBACzB,IAAI,CAAC,IAAI,EAAE,aAAa,EAAE,CAAC;YAC/B,CAAC;iBAAM,IAAI,IAAI,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;gBAC/B,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC;YACvB,CAAC;QACL,CAAC,CAAC;QArJE,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,eAAe,EAAE,CAAC;IAC7C,CAAC;IAED,qCAAqC;IACrC,IAAI,IAAI;QACJ,OAAO,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;IAChC,CAAC;aAEe,WAAM,GAAG,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAqH3B,AArHqB,CAqHpB;IA0BO,MAAM;QACX,MAAM,UAAU,GAAG,IAAI,CAAC,QAAQ,IAAI,IAAI,CAAC,OAAO,CAAC;QACjD,sEAAsE;QACtE,sEAAsE;QACtE,yDAAyD;QACzD,OAAO,IAAI,CAAA;;;4BAGS,UAAU;4BACV,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO;;yBAElC,IAAI,CAAC,QAAQ;;kBAEpB,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAA,kDAAkD,CAAC,CAAC,CAAC,EAAE;;;SAGnF,CAAC;IACN,CAAC;;AAxCD;IADC,QAAQ,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;0CACO;AAGnC;IADC,QAAQ,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;uCACJ;AAGxB;IADC,QAAQ,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;2CAC1B;AAGjB;IADC,QAAQ,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;0CAC3B;AAGhB;IADC,QAAQ,EAAE;uCACiB;AAnJnB,SAAS;IADrB,aAAa,CAAC,YAAY,CAAC;GACf,SAAS,CAgLrB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mfp-design-system/button",
3
- "version": "0.1.0",
3
+ "version": "1.0.0",
4
4
  "description": "Button web component for the mfp-design-system, built with Lit.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,7 +25,7 @@
25
25
  "lit": "^3.2.1"
26
26
  },
27
27
  "peerDependencies": {
28
- "@mfp-design-system/tokens": "^0.2.0"
28
+ "@mfp-design-system/tokens": "^0.3.0"
29
29
  },
30
30
  "peerDependenciesMeta": {
31
31
  "@mfp-design-system/tokens": {
@@ -35,7 +35,7 @@
35
35
  "devDependencies": {
36
36
  "rimraf": "^6.0.1",
37
37
  "typescript": "^5.6.3",
38
- "@mfp-design-system/tokens": "0.2.0"
38
+ "@mfp-design-system/tokens": "0.3.0"
39
39
  },
40
40
  "publishConfig": {
41
41
  "access": "public"
@@ -4,49 +4,49 @@ import './button.js';
4
4
  import type { ButtonSize, ButtonVariant } from './button.js';
5
5
 
6
6
  interface Args {
7
- variant: ButtonVariant;
8
- size: ButtonSize;
9
- disabled: boolean;
10
- loading: boolean;
11
- label: string;
7
+ variant: ButtonVariant;
8
+ size: ButtonSize;
9
+ disabled: boolean;
10
+ loading: boolean;
11
+ label: string;
12
12
  }
13
13
 
14
14
  const meta: Meta<Args> = {
15
- title: 'Components/Button',
16
- component: 'mfp-button',
17
- tags: ['autodocs'],
18
- argTypes: {
19
- variant: {
20
- control: 'select',
21
- options: ['primary', 'secondary', 'danger', 'ghost'],
22
- description: 'Visual style',
15
+ title: 'Components/Button',
16
+ component: 'mfp-button',
17
+ tags: ['autodocs'],
18
+ argTypes: {
19
+ variant: {
20
+ control: 'select',
21
+ options: ['primary', 'secondary', 'danger', 'ghost'],
22
+ description: 'Visual style',
23
+ },
24
+ size: {
25
+ control: 'select',
26
+ options: ['sm', 'md', 'lg'],
27
+ description: 'Sizing',
28
+ },
29
+ disabled: { control: 'boolean' },
30
+ loading: { control: 'boolean' },
31
+ label: { control: 'text', description: 'Button label (slot content)' },
23
32
  },
24
- size: {
25
- control: 'select',
26
- options: ['sm', 'md', 'lg'],
27
- description: 'Sizing',
33
+ args: {
34
+ variant: 'primary',
35
+ size: 'md',
36
+ disabled: false,
37
+ loading: false,
38
+ label: 'Button',
28
39
  },
29
- disabled: { control: 'boolean' },
30
- loading: { control: 'boolean' },
31
- label: { control: 'text', description: 'Button label (slot content)' },
32
- },
33
- args: {
34
- variant: 'primary',
35
- size: 'md',
36
- disabled: false,
37
- loading: false,
38
- label: 'Button',
39
- },
40
- render: (args) => html`
41
- <mfp-button
42
- variant=${args.variant}
43
- size=${args.size}
44
- ?disabled=${args.disabled}
45
- ?loading=${args.loading}
46
- >
47
- ${args.label}
48
- </mfp-button>
49
- `,
40
+ render: (args) => html`
41
+ <mfp-button
42
+ variant=${args.variant}
43
+ size=${args.size}
44
+ ?disabled=${args.disabled}
45
+ ?loading=${args.loading}
46
+ >
47
+ ${args.label}
48
+ </mfp-button>
49
+ `,
50
50
  };
51
51
 
52
52
  export default meta;
@@ -54,60 +54,60 @@ export default meta;
54
54
  type Story = StoryObj<Args>;
55
55
 
56
56
  export const Primary: Story = {
57
- args: { variant: 'primary', label: 'Primary' },
57
+ args: { variant: 'primary', label: 'Primary' },
58
58
  };
59
59
 
60
60
  export const Secondary: Story = {
61
- args: { variant: 'secondary', label: 'Secondary' },
61
+ args: { variant: 'secondary', label: 'Secondary' },
62
62
  };
63
63
 
64
64
  export const Danger: Story = {
65
- args: { variant: 'danger', label: 'Delete' },
65
+ args: { variant: 'danger', label: 'Delete' },
66
66
  };
67
67
 
68
68
  export const Ghost: Story = {
69
- args: { variant: 'ghost', label: 'More info' },
69
+ args: { variant: 'ghost', label: 'More info' },
70
70
  };
71
71
 
72
72
  export const Sizes: Story = {
73
- parameters: { controls: { disable: true } },
74
- render: () => html`
75
- <div style="display: flex; gap: 12px; align-items: center;">
76
- <mfp-button size="sm">Small</mfp-button>
77
- <mfp-button size="md">Medium</mfp-button>
78
- <mfp-button size="lg">Large</mfp-button>
79
- </div>
80
- `,
73
+ parameters: { controls: { disable: true } },
74
+ render: () => html`
75
+ <div style="display: flex; gap: 12px; align-items: center;">
76
+ <mfp-button size="sm">Small</mfp-button>
77
+ <mfp-button size="md">Medium</mfp-button>
78
+ <mfp-button size="lg">Large</mfp-button>
79
+ </div>
80
+ `,
81
81
  };
82
82
 
83
83
  export const AllVariants: Story = {
84
- parameters: { controls: { disable: true } },
85
- render: () => html`
86
- <div style="display: flex; gap: 12px; flex-wrap: wrap;">
87
- <mfp-button variant="primary">Primary</mfp-button>
88
- <mfp-button variant="secondary">Secondary</mfp-button>
89
- <mfp-button variant="danger">Danger</mfp-button>
90
- <mfp-button variant="ghost">Ghost</mfp-button>
91
- </div>
92
- `,
84
+ parameters: { controls: { disable: true } },
85
+ render: () => html`
86
+ <div style="display: flex; gap: 12px; flex-wrap: wrap;">
87
+ <mfp-button variant="primary">Primary</mfp-button>
88
+ <mfp-button variant="secondary">Secondary</mfp-button>
89
+ <mfp-button variant="danger">Danger</mfp-button>
90
+ <mfp-button variant="ghost">Ghost</mfp-button>
91
+ </div>
92
+ `,
93
93
  };
94
94
 
95
95
  export const Disabled: Story = {
96
- args: { disabled: true, label: 'Disabled' },
96
+ args: { disabled: true, label: 'Disabled' },
97
97
  };
98
98
 
99
99
  export const Loading: Story = {
100
- args: { loading: true, label: 'Saving…' },
100
+ args: { loading: true, label: 'Saving…' },
101
101
  };
102
102
 
103
103
  export const LoadingStates: Story = {
104
- parameters: { controls: { disable: true } },
105
- render: () => html`
106
- <div style="display: flex; gap: 12px; flex-wrap: wrap;">
107
- <mfp-button variant="primary" loading>Saving…</mfp-button>
108
- <mfp-button variant="secondary" loading>Loading</mfp-button>
109
- <mfp-button variant="danger" loading>Deleting</mfp-button>
110
- <mfp-button variant="ghost" loading>…</mfp-button>
111
- </div>
112
- `,
104
+ parameters: { controls: { disable: true } },
105
+ render: () => html`
106
+ <div style="display: flex; gap: 12px; flex-wrap: wrap;">
107
+ <mfp-button variant="primary" loading>Saving…</mfp-button>
108
+ <mfp-button variant="secondary" loading>Loading</mfp-button>
109
+ <mfp-button variant="danger" loading>Deleting</mfp-button>
110
+ <mfp-button variant="ghost" loading>…</mfp-button>
111
+ </div>
112
+ `,
113
113
  };
@@ -0,0 +1,79 @@
1
+ import { expect, fixture, html } from '@open-wc/testing';
2
+ import './button.js';
3
+ import type { MfpButton } from './button.js';
4
+
5
+ describe('<mfp-button>', () => {
6
+ it('renders with default variant and size', async () => {
7
+ const el = await fixture<MfpButton>(html`<mfp-button>Click</mfp-button>`);
8
+ expect(el.variant).to.equal('primary');
9
+ expect(el.size).to.equal('md');
10
+ expect(el.shadowRoot?.querySelector('button')).to.exist;
11
+ });
12
+
13
+ it('reflects variant and size to attributes', async () => {
14
+ const el = await fixture<MfpButton>(
15
+ html`<mfp-button variant="danger" size="lg">Delete</mfp-button>`,
16
+ );
17
+ expect(el.getAttribute('variant')).to.equal('danger');
18
+ expect(el.getAttribute('size')).to.equal('lg');
19
+ });
20
+
21
+ it('forwards clicks via the click event', async () => {
22
+ const el = await fixture<MfpButton>(html`<mfp-button>Click</mfp-button>`);
23
+ let clicks = 0;
24
+ el.addEventListener('click', () => clicks++);
25
+ el.shadowRoot!.querySelector('button')!.click();
26
+ expect(clicks).to.equal(1);
27
+ });
28
+
29
+ it('does not fire submit when disabled', async () => {
30
+ const form = await fixture<HTMLFormElement>(html`
31
+ <form>
32
+ <mfp-button type="submit" disabled>Submit</mfp-button>
33
+ </form>
34
+ `);
35
+ let submits = 0;
36
+ form.addEventListener('submit', (e) => {
37
+ e.preventDefault();
38
+ submits++;
39
+ });
40
+ const btn = form.querySelector<MfpButton>('mfp-button')!;
41
+ btn.shadowRoot!.querySelector('button')!.click();
42
+ expect(submits).to.equal(0);
43
+ });
44
+
45
+ it('submits its associated form when type="submit" is clicked', async () => {
46
+ const form = await fixture<HTMLFormElement>(html`
47
+ <form>
48
+ <input name="email" value="me@example.com" />
49
+ <mfp-button type="submit">Submit</mfp-button>
50
+ </form>
51
+ `);
52
+ let submits = 0;
53
+ form.addEventListener('submit', (e) => {
54
+ e.preventDefault();
55
+ submits++;
56
+ });
57
+ const btn = form.querySelector<MfpButton>('mfp-button')!;
58
+ btn.shadowRoot!.querySelector('button')!.click();
59
+ // requestSubmit is async; wait a microtask
60
+ await new Promise((r) => setTimeout(r, 0));
61
+ expect(submits).to.equal(1);
62
+ });
63
+
64
+ it('exposes the associated form via the .form getter', async () => {
65
+ const form = await fixture<HTMLFormElement>(html`
66
+ <form id="my-form">
67
+ <mfp-button type="submit">Submit</mfp-button>
68
+ </form>
69
+ `);
70
+ const btn = form.querySelector<MfpButton>('mfp-button')!;
71
+ expect(btn.form).to.equal(form);
72
+ });
73
+
74
+ it('shows spinner when loading', async () => {
75
+ const el = await fixture<MfpButton>(html`<mfp-button loading>Saving…</mfp-button>`);
76
+ expect(el.shadowRoot?.querySelector('.spinner')).to.exist;
77
+ expect(el.shadowRoot?.querySelector('button')?.getAttribute('aria-busy')).to.equal('true');
78
+ });
79
+ });
package/src/button.ts CHANGED
@@ -7,158 +7,185 @@ export type ButtonType = 'button' | 'submit' | 'reset';
7
7
 
8
8
  @customElement('mfp-button')
9
9
  export class MfpButton extends LitElement {
10
- static override styles = css`
11
- :host {
12
- display: inline-block;
13
- }
14
-
15
- button {
16
- font-family: var(--font-family-sans, system-ui, -apple-system, sans-serif);
17
- font-weight: var(--font-weight-medium, 500);
18
- line-height: var(--font-line-height-tight, 1.2);
19
- border: 1px solid transparent;
20
- border-radius: var(--size-radius-md, 8px);
21
- cursor: pointer;
22
- display: inline-flex;
23
- align-items: center;
24
- justify-content: center;
25
- gap: var(--size-spacing-2, 8px);
26
- white-space: nowrap;
27
- user-select: none;
28
- transition:
29
- background var(--motion-duration-fast, 150ms) var(--motion-easing-standard, ease),
30
- border-color var(--motion-duration-fast, 150ms) var(--motion-easing-standard, ease),
31
- color var(--motion-duration-fast, 150ms) var(--motion-easing-standard, ease),
32
- box-shadow var(--motion-duration-fast, 150ms) var(--motion-easing-standard, ease);
33
- }
34
-
35
- button:focus-visible {
36
- outline: 2px solid var(--color-status-info-solid, #2563eb);
37
- outline-offset: 2px;
38
- }
39
-
40
- button:disabled {
41
- cursor: not-allowed;
42
- opacity: 0.5;
43
- }
44
-
45
- /* Sizes fall back to medium when no [size] attribute is set */
46
- :host(:not([size])) button,
47
- :host([size='md']) button {
48
- padding: var(--size-spacing-2, 8px) var(--size-spacing-4, 16px);
49
- font-size: var(--font-size-base, 16px);
50
- min-height: 40px;
51
- }
52
- :host([size='sm']) button {
53
- padding: var(--size-spacing-1, 4px) var(--size-spacing-3, 12px);
54
- font-size: var(--font-size-sm, 14px);
55
- min-height: 32px;
56
- }
57
- :host([size='lg']) button {
58
- padding: var(--size-spacing-3, 12px) var(--size-spacing-5, 20px);
59
- font-size: var(--font-size-lg, 18px);
60
- min-height: 48px;
61
- }
62
-
63
- /* Variants — fall back to primary when no [variant] attribute is set */
64
- :host(:not([variant])) button,
65
- :host([variant='primary']) button {
66
- background: var(--color-status-info-solid, #2563eb);
67
- color: var(--color-neutral-0, #ffffff);
68
- }
69
- :host(:not([variant])) button:hover:not(:disabled),
70
- :host([variant='primary']) button:hover:not(:disabled) {
71
- background: var(--color-status-info-fg, #1e40af);
72
- box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.08));
73
- }
74
-
75
- :host([variant='secondary']) button {
76
- background: var(--color-neutral-0, #ffffff);
77
- color: var(--color-text-default, #111827);
78
- border-color: var(--color-border-default, #e5e7eb);
79
- }
80
- :host([variant='secondary']) button:hover:not(:disabled) {
81
- background: var(--color-background-subtle, #f9fafb);
82
- border-color: var(--color-border-strong, #9ca3af);
83
- }
84
-
85
- :host([variant='danger']) button {
86
- background: var(--color-status-error-solid, #dc2626);
87
- color: var(--color-neutral-0, #ffffff);
88
- }
89
- :host([variant='danger']) button:hover:not(:disabled) {
90
- background: var(--color-status-error-fg, #991b1b);
91
- box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.08));
92
- }
93
-
94
- :host([variant='ghost']) button {
95
- background: transparent;
96
- color: var(--color-text-default, #111827);
97
- }
98
- :host([variant='ghost']) button:hover:not(:disabled) {
99
- background: var(--color-background-muted, #f3f4f6);
100
- }
101
-
102
- /* Loading spinner — sized relative to current font-size */
103
- .spinner {
104
- width: 1em;
105
- height: 1em;
106
- border: 2px solid currentColor;
107
- border-top-color: transparent;
108
- border-radius: 50%;
109
- animation: mfp-button-spin 0.6s linear infinite;
110
- flex: none;
111
- }
112
-
113
- @keyframes mfp-button-spin {
114
- to {
115
- transform: rotate(360deg);
116
- }
117
- }
10
+ static formAssociated = true;
11
+
12
+ private _internals: ElementInternals;
13
+
14
+ constructor() {
15
+ super();
16
+ this._internals = this.attachInternals();
17
+ }
18
+
19
+ /** The associated <form>, if any. */
20
+ get form(): HTMLFormElement | null {
21
+ return this._internals.form;
22
+ }
23
+
24
+ static override styles = css`
25
+ :host {
26
+ display: inline-block;
27
+ }
28
+
29
+ button {
30
+ font-family: var(--font-family-sans, system-ui, -apple-system, sans-serif);
31
+ font-weight: var(--font-weight-medium, 500);
32
+ line-height: var(--font-line-height-tight, 1.2);
33
+ border: 1px solid transparent;
34
+ border-radius: var(--size-radius-md, 8px);
35
+ cursor: pointer;
36
+ display: inline-flex;
37
+ align-items: center;
38
+ justify-content: center;
39
+ gap: var(--size-spacing-2, 8px);
40
+ white-space: nowrap;
41
+ user-select: none;
42
+ transition:
43
+ background var(--motion-duration-fast, 150ms) var(--motion-easing-standard, ease),
44
+ border-color var(--motion-duration-fast, 150ms) var(--motion-easing-standard, ease),
45
+ color var(--motion-duration-fast, 150ms) var(--motion-easing-standard, ease),
46
+ box-shadow var(--motion-duration-fast, 150ms) var(--motion-easing-standard, ease);
47
+ }
48
+
49
+ button:focus-visible {
50
+ outline: 2px solid var(--color-brand-primary, #2563eb);
51
+ outline-offset: 2px;
52
+ }
53
+
54
+ button:disabled {
55
+ cursor: not-allowed;
56
+ opacity: 0.5;
57
+ }
58
+
59
+ /* Sizes — fall back to medium when no [size] attribute is set */
60
+ :host(:not([size])) button,
61
+ :host([size='md']) button {
62
+ padding: var(--size-spacing-2, 8px) var(--size-spacing-4, 16px);
63
+ font-size: var(--font-size-base, 16px);
64
+ min-height: 40px;
65
+ }
66
+ :host([size='sm']) button {
67
+ padding: var(--size-spacing-1, 4px) var(--size-spacing-3, 12px);
68
+ font-size: var(--font-size-sm, 14px);
69
+ min-height: 32px;
70
+ }
71
+ :host([size='lg']) button {
72
+ padding: var(--size-spacing-3, 12px) var(--size-spacing-5, 20px);
73
+ font-size: var(--font-size-lg, 18px);
74
+ min-height: 48px;
75
+ }
76
+
77
+ /* Variants — fall back to primary when no [variant] attribute is set */
78
+ :host(:not([variant])) button,
79
+ :host([variant='primary']) button {
80
+ background: var(--color-brand-primary, #2563eb);
81
+ color: var(--color-brand-primary-fg, #ffffff);
82
+ }
83
+ :host(:not([variant])) button:hover:not(:disabled),
84
+ :host([variant='primary']) button:hover:not(:disabled) {
85
+ background: var(--color-brand-primary-hover, #1d4ed8);
86
+ box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.08));
87
+ }
88
+
89
+ :host([variant='secondary']) button {
90
+ background: var(--color-neutral-0, #ffffff);
91
+ color: var(--color-text-default, #111827);
92
+ border-color: var(--color-border-default, #e5e7eb);
93
+ }
94
+ :host([variant='secondary']) button:hover:not(:disabled) {
95
+ background: var(--color-background-subtle, #f9fafb);
96
+ border-color: var(--color-border-strong, #9ca3af);
97
+ }
98
+
99
+ :host([variant='danger']) button {
100
+ background: var(--color-status-error-solid, #dc2626);
101
+ color: var(--color-neutral-0, #ffffff);
102
+ }
103
+ :host([variant='danger']) button:hover:not(:disabled) {
104
+ background: var(--color-status-error-fg, #991b1b);
105
+ box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.08));
106
+ }
107
+
108
+ :host([variant='ghost']) button {
109
+ background: transparent;
110
+ color: var(--color-text-default, #111827);
111
+ }
112
+ :host([variant='ghost']) button:hover:not(:disabled) {
113
+ background: var(--color-background-muted, #f3f4f6);
114
+ }
115
+
116
+ /* Loading spinner — sized relative to current font-size */
117
+ .spinner {
118
+ width: 1em;
119
+ height: 1em;
120
+ border: 2px solid currentColor;
121
+ border-top-color: transparent;
122
+ border-radius: 50%;
123
+ animation: mfp-button-spin 0.6s linear infinite;
124
+ flex: none;
125
+ }
126
+
127
+ @keyframes mfp-button-spin {
128
+ to {
129
+ transform: rotate(360deg);
130
+ }
131
+ }
132
+
133
+ @media (prefers-reduced-motion: reduce) {
134
+ button {
135
+ transition: none;
136
+ }
137
+ .spinner {
138
+ animation-duration: 1.5s;
139
+ }
140
+ }
141
+ `;
118
142
 
119
- @media (prefers-reduced-motion: reduce) {
120
- button {
121
- transition: none;
122
- }
123
- .spinner {
124
- animation-duration: 1.5s;
125
- }
143
+ @property({ reflect: true })
144
+ variant: ButtonVariant = 'primary';
145
+
146
+ @property({ reflect: true })
147
+ size: ButtonSize = 'md';
148
+
149
+ @property({ type: Boolean, reflect: true })
150
+ disabled = false;
151
+
152
+ @property({ type: Boolean, reflect: true })
153
+ loading = false;
154
+
155
+ @property()
156
+ type: ButtonType = 'button';
157
+
158
+ private _onClick = () => {
159
+ if (this.disabled || this.loading) return;
160
+ if (this.type === 'submit') {
161
+ this.form?.requestSubmit();
162
+ } else if (this.type === 'reset') {
163
+ this.form?.reset();
164
+ }
165
+ };
166
+
167
+ override render() {
168
+ const isInactive = this.disabled || this.loading;
169
+ // The inner button is always type="button" so it can't trigger native
170
+ // form behavior from inside shadow DOM. The host handles submit/reset
171
+ // via the click handler above and ElementInternals.form.
172
+ return html`
173
+ <button
174
+ type="button"
175
+ ?disabled=${isInactive}
176
+ aria-busy=${this.loading ? 'true' : 'false'}
177
+ part="button"
178
+ @click=${this._onClick}
179
+ >
180
+ ${this.loading ? html`<span class="spinner" aria-hidden="true"></span>` : ''}
181
+ <slot></slot>
182
+ </button>
183
+ `;
126
184
  }
127
- `;
128
-
129
- @property({ reflect: true })
130
- variant: ButtonVariant = 'primary';
131
-
132
- @property({ reflect: true })
133
- size: ButtonSize = 'md';
134
-
135
- @property({ type: Boolean, reflect: true })
136
- disabled = false;
137
-
138
- @property({ type: Boolean, reflect: true })
139
- loading = false;
140
-
141
- @property()
142
- type: ButtonType = 'button';
143
-
144
- override render() {
145
- const isInactive = this.disabled || this.loading;
146
- return html`
147
- <button
148
- type=${this.type}
149
- ?disabled=${isInactive}
150
- aria-busy=${this.loading ? 'true' : 'false'}
151
- part="button"
152
- >
153
- ${this.loading ? html`<span class="spinner" aria-hidden="true"></span>` : ''}
154
- <slot></slot>
155
- </button>
156
- `;
157
- }
158
185
  }
159
186
 
160
187
  declare global {
161
- interface HTMLElementTagNameMap {
162
- 'mfp-button': MfpButton;
163
- }
188
+ interface HTMLElementTagNameMap {
189
+ 'mfp-button': MfpButton;
190
+ }
164
191
  }