@grantcodes/ui 2.11.1 → 2.12.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/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.12.0](https://github.com/grantcodes/ui/compare/ui-v2.11.2...ui-v2.12.0) (2026-05-03)
4
+
5
+
6
+ ### Features
7
+
8
+ * **22-button-face-native-behavior:** add FACE static properties and ElementInternals wiring ([b6ee7f4](https://github.com/grantcodes/ui/commit/b6ee7f4084f2916cd6799c0a79ec13cf87c3c0e5))
9
+ * **22-button-face-native-behavior:** add form lifecycle callbacks and initial setFormValue ([e3f015c](https://github.com/grantcodes/ui/commit/e3f015c40c1d67cfb970d028745bad2c126eb976))
10
+ * **22-button-face-native-behavior:** fix FACE form behavior implementation ([c7b8caa](https://github.com/grantcodes/ui/commit/c7b8caa534b1d85049c89b5121cb67c39914f9f2))
11
+ * **22-button-face-native-behavior:** wire form click handler for submit/reset ([4c9ef53](https://github.com/grantcodes/ui/commit/4c9ef535f8d2a5df711042036b8c855cf23f3ce5))
12
+
13
+ ## [2.11.2](https://github.com/grantcodes/ui/compare/ui-v2.11.1...ui-v2.11.2) (2026-05-03)
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * **ui:** add horizontal property to form field ([1a0bbfa](https://github.com/grantcodes/ui/commit/1a0bbfa1ed69e341c5e43ee0678bcb9d0f0469a7))
19
+
3
20
  ## [2.11.1](https://github.com/grantcodes/ui/compare/ui-v2.11.0...ui-v2.11.1) (2026-05-03)
4
21
 
5
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grantcodes/ui",
3
- "version": "2.11.1",
3
+ "version": "2.12.0",
4
4
  "description": "A personal component system built with Lit web components",
5
5
  "type": "module",
6
6
  "main": "src/main.js",
@@ -10,12 +10,15 @@ export class GrantCodesButton extends LitElement {
10
10
  // via CSS custom properties.
11
11
  static styles = [focusRingStyles, buttonStyles];
12
12
 
13
+ static formAssociated = true;
14
+
13
15
  static properties = {
14
16
  href: { type: String },
15
17
  type: { type: String },
16
18
  name: { type: String },
17
19
  value: { type: String },
18
20
  disabled: { type: Boolean, reflect: true },
21
+ form: { type: String, reflect: true },
19
22
  };
20
23
 
21
24
  constructor() {
@@ -25,9 +28,65 @@ export class GrantCodesButton extends LitElement {
25
28
  this.type = "button";
26
29
  this.name = "";
27
30
  this.value = "";
31
+ // Do NOT set form="" — an empty form attribute overrides ancestor form association
32
+ this._fieldsetDisabled = false;
33
+
34
+ // Attach ElementInternals for form participation.
35
+ // try/catch because happy-dom (DOM-shim test env) may not support attachInternals()
36
+ try {
37
+ this.internals = this.attachInternals();
38
+ } catch (e) {
39
+ this.internals = null;
40
+ }
28
41
  this.disabled = false;
29
42
  }
30
43
 
44
+ updated(changedProperties) {
45
+ super.updated(changedProperties);
46
+ if (this.internals) {
47
+ if (changedProperties.has("name") || changedProperties.has("value")) {
48
+ this.internals.setFormValue(this.name ? this.value : null);
49
+ }
50
+ }
51
+ }
52
+
53
+ connectedCallback() {
54
+ super.connectedCallback();
55
+ this.addEventListener("click", this._handleFormClick);
56
+ }
57
+
58
+ disconnectedCallback() {
59
+ super.disconnectedCallback();
60
+ this.removeEventListener("click", this._handleFormClick);
61
+ }
62
+
63
+ _handleFormClick(e) {
64
+ // href mode renders a link — do not intercept form actions
65
+ if (this.href) return;
66
+ // Only handle form actions when we have internals and are inside a form
67
+ if (!this.internals || !this.internals.form || this.disabled) return;
68
+
69
+ const t = this.type.toLowerCase();
70
+ if (t === "submit") {
71
+ this.internals.form.requestSubmit();
72
+ } else if (t === "reset") {
73
+ this.internals.form.reset();
74
+ }
75
+ // type === "button" or any other value: no form action (default no-op)
76
+ }
77
+
78
+ formDisabledCallback(disabled) {
79
+ this._fieldsetDisabled = disabled;
80
+ this.requestUpdate();
81
+ }
82
+
83
+ formResetCallback() {
84
+ // Called by the browser when the associated form is reset.
85
+ // Reset the value to an empty string (default state).
86
+ // If a future version tracks an initial value, restore it here.
87
+ this.value = "";
88
+ }
89
+
31
90
  // The render() method is called any time reactive properties change.
32
91
  // Return HTML in a string template literal tagged with the `html`
33
92
  // tag function to describe the component's internal DOM.
@@ -42,7 +101,7 @@ export class GrantCodesButton extends LitElement {
42
101
  <a
43
102
  class="button focus-ring"
44
103
  href=${this.href}
45
- ?disabled=${this.disabled}
104
+ ?disabled=${this.disabled || this._fieldsetDisabled}
46
105
  >
47
106
  <span><slot></slot></span>
48
107
  </a>
@@ -52,10 +111,10 @@ export class GrantCodesButton extends LitElement {
52
111
  return html`
53
112
  <button
54
113
  class="button focus-ring"
55
- type=${this.type}
114
+ type="button"
56
115
  name=${this.name ?? ""}
57
116
  value=${this.value ?? ""}
58
- ?disabled=${this.disabled}
117
+ ?disabled=${this.disabled || this._fieldsetDisabled}
59
118
  >
60
119
  <span><slot></slot></span>
61
120
  </button>
@@ -1,6 +1,7 @@
1
1
  import { describe, it, afterEach } from "node:test";
2
2
  import { strict as assert } from "node:assert";
3
3
  import { fixture, cleanup, click } from "../../test-utils/index.js";
4
+ import { GrantCodesButton } from "./button.component.js";
4
5
  import "./button.js";
5
6
 
6
7
  describe("Button Component", () => {
@@ -84,8 +85,11 @@ describe("Button Component", () => {
84
85
 
85
86
  for (const type of types) {
86
87
  const testElement = await fixture("grantcodes-button", { type });
88
+ // Internal button is always type="button" — form behavior is handled
89
+ // by the host element via FACE (requestSubmit/reset on ElementInternals)
87
90
  const button = testElement.shadowRoot.querySelector("button");
88
- assert.strictEqual(button.type, type, `Button type should be ${type}`);
91
+ assert.strictEqual(button.type, "button", `Internal button should always be type="button"`);
92
+ assert.strictEqual(testElement.type, type, `Host type property should be ${type}`);
89
93
  cleanup(testElement);
90
94
  }
91
95
  });
@@ -100,3 +104,246 @@ describe("Button Component", () => {
100
104
  assert.ok(slot, "Slot should exist");
101
105
  });
102
106
  });
107
+
108
+ describe("Form Context", () => {
109
+ let element;
110
+ let form;
111
+
112
+ afterEach(() => {
113
+ cleanup(element);
114
+ cleanup(form);
115
+ });
116
+
117
+ it("should render button inside a form element", async () => {
118
+ form = document.createElement("form");
119
+ document.body.appendChild(form);
120
+
121
+ element = document.createElement("grantcodes-button");
122
+ form.appendChild(element);
123
+ await element.updateComplete;
124
+
125
+ const button = element.shadowRoot.querySelector("button");
126
+ assert.ok(button, "Button should exist in shadow DOM when inside form");
127
+ const link = element.shadowRoot.querySelector("a");
128
+ assert.strictEqual(link, null, "Should not render a link when inside form");
129
+ });
130
+
131
+ it("should use type='button' as default on internal button", async () => {
132
+ element = await fixture("grantcodes-button");
133
+
134
+ const button = element.shadowRoot.querySelector("button");
135
+ assert.strictEqual(button.type, "button", "Default type should be 'button'");
136
+ });
137
+
138
+ it("should keep internal button as type='button' when host type is submit", async () => {
139
+ element = await fixture("grantcodes-button", { type: "submit" });
140
+
141
+ const button = element.shadowRoot.querySelector("button");
142
+ // Internal button is always type="button" — FACE handles submit via requestSubmit()
143
+ assert.strictEqual(button.type, "button", "Internal button should be type='button'");
144
+ assert.strictEqual(element.type, "submit", "Host type property should be 'submit'");
145
+ });
146
+
147
+ it("should keep internal button as type='button' when host type is reset", async () => {
148
+ element = await fixture("grantcodes-button", { type: "reset" });
149
+
150
+ const button = element.shadowRoot.querySelector("button");
151
+ // Internal button is always type="button" — FACE handles reset via form.reset()
152
+ assert.strictEqual(button.type, "button", "Internal button should be type='button'");
153
+ assert.strictEqual(element.type, "reset", "Host type property should be 'reset'");
154
+ });
155
+
156
+ it("should reflect name property on internal button", async () => {
157
+ element = await fixture("grantcodes-button", { name: "myButton" });
158
+
159
+ const button = element.shadowRoot.querySelector("button");
160
+ assert.strictEqual(
161
+ button.getAttribute("name"),
162
+ "myButton",
163
+ "Internal button name attribute should be 'myButton'",
164
+ );
165
+ });
166
+
167
+ it("should reflect value property on internal button", async () => {
168
+ element = await fixture("grantcodes-button", { value: "send" });
169
+
170
+ const button = element.shadowRoot.querySelector("button");
171
+ assert.strictEqual(
172
+ button.getAttribute("value"),
173
+ "send",
174
+ "Internal button value attribute should be 'send'",
175
+ );
176
+ });
177
+
178
+ it("should reflect disabled property on internal button", async () => {
179
+ element = await fixture("grantcodes-button", { disabled: true });
180
+
181
+ const button = element.shadowRoot.querySelector("button");
182
+ assert.strictEqual(button.disabled, true, "Internal button should be disabled");
183
+ assert.ok(
184
+ button.hasAttribute("disabled"),
185
+ "Internal button should have disabled attribute",
186
+ );
187
+ });
188
+
189
+ it("should render as link not button when href is set", async () => {
190
+ element = await fixture("grantcodes-button", {
191
+ href: "https://example.com",
192
+ });
193
+ await element.updateComplete;
194
+
195
+ const button = element.shadowRoot.querySelector("button");
196
+ const link = element.shadowRoot.querySelector("a");
197
+ assert.strictEqual(button, null, "Should not render a button when href is set");
198
+ assert.ok(link, "Should render a link when href is set");
199
+ const href = link.getAttribute("href");
200
+ assert.ok(
201
+ href === "https://example.com" || href.includes("example.com"),
202
+ "Link href should contain example.com",
203
+ );
204
+ });
205
+ });
206
+
207
+ describe("FACE Infrastructure", () => {
208
+ let element;
209
+
210
+ afterEach(() => {
211
+ cleanup(element);
212
+ });
213
+
214
+ it("should declare formAssociated as true", () => {
215
+ assert.strictEqual(
216
+ GrantCodesButton.formAssociated,
217
+ true,
218
+ "GrantCodesButton.formAssociated should be true",
219
+ );
220
+ });
221
+
222
+ it("should have internals property after construction", async () => {
223
+ element = await fixture("grantcodes-button");
224
+ // internals may be null in happy-dom (attachInternals throws), but the
225
+ // property must exist — it was assigned in the constructor's try/catch.
226
+ assert.ok(
227
+ "internals" in element,
228
+ "Element should have internals property",
229
+ );
230
+ // In happy-dom: element.internals === null (expected — DOM shim limitation)
231
+ // In real browser: element.internals instanceof ElementInternals
232
+ });
233
+
234
+ it("should reflect form attribute when set as property", async () => {
235
+ element = await fixture("grantcodes-button", {
236
+ form: "myForm",
237
+ });
238
+ await element.updateComplete;
239
+
240
+ assert.strictEqual(
241
+ element.getAttribute("form"),
242
+ "myForm",
243
+ "form attribute should reflect property value 'myForm'",
244
+ );
245
+ assert.strictEqual(
246
+ element.form,
247
+ "myForm",
248
+ "element.form property should be 'myForm'",
249
+ );
250
+ });
251
+
252
+ it("should not have form attribute by default", async () => {
253
+ element = await fixture("grantcodes-button");
254
+ await element.updateComplete;
255
+
256
+ const formAttr = element.getAttribute("form");
257
+ assert.ok(
258
+ formAttr === null || formAttr === "",
259
+ "form attribute should be null or empty by default",
260
+ );
261
+ });
262
+
263
+ it("should use type='button' on internal button after FACE changes", async () => {
264
+ element = await fixture("grantcodes-button", { type: "submit" });
265
+
266
+ const button = element.shadowRoot.querySelector("button");
267
+ assert.ok(button, "Internal button should exist");
268
+ assert.strictEqual(
269
+ button.type,
270
+ "button",
271
+ "Internal button should be type='button' — FACE handles submit via requestSubmit()",
272
+ );
273
+ });
274
+
275
+ it("should still render as link not button when href is set", async () => {
276
+ element = await fixture("grantcodes-button", {
277
+ href: "https://example.com",
278
+ });
279
+ await element.updateComplete;
280
+
281
+ const button = element.shadowRoot.querySelector("button");
282
+ const link = element.shadowRoot.querySelector("a");
283
+ assert.strictEqual(button, null, "Should not render a button when href is set");
284
+ assert.ok(link, "Should render a link when href is set");
285
+ });
286
+
287
+ it("should have disabled attribute on internal button when disabled", async () => {
288
+ element = await fixture("grantcodes-button", {
289
+ disabled: true,
290
+ });
291
+
292
+ const button = element.shadowRoot.querySelector("button");
293
+ assert.ok(button, "Internal button should exist");
294
+ assert.strictEqual(
295
+ button.disabled,
296
+ true,
297
+ "Internal button should be disabled when element.disabled is true",
298
+ );
299
+ assert.ok(
300
+ button.hasAttribute("disabled"),
301
+ "Internal button should have disabled attribute",
302
+ );
303
+ });
304
+
305
+ it("should not throw when clicking internal button while disabled", async () => {
306
+ element = await fixture("grantcodes-button", {
307
+ disabled: true,
308
+ type: "submit",
309
+ });
310
+ await element.updateComplete;
311
+
312
+ const button = element.shadowRoot.querySelector("button");
313
+ assert.ok(button, "Internal button should exist");
314
+
315
+ // _handleFormClick should early-return when this.disabled is true.
316
+ // Verify clicking does not throw (no internals.form access attempted).
317
+ let threw = false;
318
+ try {
319
+ button.click();
320
+ } catch (e) {
321
+ threw = true;
322
+ }
323
+ assert.strictEqual(threw, false, "Clicking disabled button should not throw");
324
+ });
325
+
326
+ it("should keep internal button as type='button' when host type is reset", async () => {
327
+ element = await fixture("grantcodes-button", { type: "reset" });
328
+
329
+ const button = element.shadowRoot.querySelector("button");
330
+ assert.ok(button, "Internal button should exist");
331
+ assert.strictEqual(
332
+ button.type,
333
+ "button",
334
+ "Internal button should be type='button' — FACE handles reset via form.reset()",
335
+ );
336
+ });
337
+
338
+ it("should have type='button' as default on internal button", async () => {
339
+ element = await fixture("grantcodes-button");
340
+
341
+ const button = element.shadowRoot.querySelector("button");
342
+ assert.ok(button, "Internal button should exist");
343
+ assert.strictEqual(
344
+ button.type,
345
+ "button",
346
+ "Default internal button type should be 'button'",
347
+ );
348
+ });
349
+ });
@@ -1,5 +1,6 @@
1
1
  import { LitElement } from "lit";
2
2
  import { html } from "lit/static-html.js";
3
+ import { classMap } from "lit/directives/class-map.js";
3
4
  import formFieldStyles from "./form-field.css" with { type: "css" };
4
5
  import { generateId } from "../../lib/generate-id.js";
5
6
 
@@ -8,7 +9,8 @@ export class GrantCodesFormField extends LitElement {
8
9
  static styles = [formFieldStyles];
9
10
 
10
11
  static properties = {
11
- label: { type: String, reflect: true },
12
+ label: { type: String, reflect: true },
13
+ direction: { type: String },
12
14
  error: { type: String },
13
15
  help: { type: String },
14
16
  };
@@ -20,7 +22,13 @@ export class GrantCodesFormField extends LitElement {
20
22
  this.error = undefined;
21
23
  this.help = undefined;
22
24
 
23
- this.groupInput = false;
25
+ this.groupInput = false;
26
+
27
+ /**
28
+ * Direction of the field. Generally want horizontal for checkboxes and radios.
29
+ * @type {'vertical' | 'horizontal'}
30
+ */
31
+ this.direction = "vertical";
24
32
 
25
33
  /** @type {NodeListOf<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>} */
26
34
  this.inputElements;
@@ -123,19 +131,23 @@ export class GrantCodesFormField extends LitElement {
123
131
  `;
124
132
  }
125
133
 
126
- render() {
134
+ render() {
135
+ const wrapperClass = classMap({
136
+ 'form-field': true,
137
+ 'form-field--horizontal': this.direction === 'horizontal'
138
+ })
127
139
  if (this.groupInput) {
128
140
  return html`
129
- <fieldset class="form-field">
141
+ <fieldset class=${wrapperClass}>
130
142
  <legend class="form-field__label">${this.label}</legend>
131
143
  <slot></slot>
132
144
  ${this.errorTemplate()}
133
145
  </fieldset>
134
146
  `;
135
- }
147
+ }
136
148
 
137
149
  return html`
138
- <div class="form-field">
150
+ <div class=${wrapperClass}>
139
151
  <label>
140
152
  <span class="form-field__label" @click=${this.handleLabelClick}
141
153
  >${this.label}</span
@@ -20,10 +20,11 @@
20
20
  gap: inherit;
21
21
  }
22
22
 
23
- :host(:has(> input[type="checkbox"], > input[type="radio"])) .form-field label {
23
+ .form-field--horizontal label {
24
24
  flex-direction: row-reverse;
25
25
  align-items: center;
26
26
  justify-content: flex-end;
27
+ gap: var(--g-theme-spacing-sm);
27
28
  }
28
29
 
29
30
  .form-field__label {
@@ -42,7 +42,7 @@ export const FormFieldWithHelp = {
42
42
  };
43
43
 
44
44
  export const FormFieldWithSelect = {
45
- args: {
45
+ args: {
46
46
  slot: html`
47
47
  <select>
48
48
  <option value="1">Option 1</option>
@@ -60,13 +60,15 @@ export const FormFieldWithTextArea = {
60
60
  };
61
61
 
62
62
  export const FormFieldWithCheckbox = {
63
- args: {
63
+ args: {
64
+ direction: 'horizontal',
64
65
  slot: html`<input type="checkbox" value="1" name="name" />`,
65
66
  },
66
67
  };
67
68
 
68
69
  export const FormFieldWithRadio = {
69
- args: {
70
+ args: {
71
+ direction: 'horizontal',
70
72
  slot: html`<input type="radio" />`,
71
73
  },
72
74
  };
@@ -74,13 +76,13 @@ export const FormFieldWithRadio = {
74
76
  export const FormFieldWithRadioGroup = {
75
77
  args: {
76
78
  slot: html`
77
- <grantcodes-form-field label="Radio number 1">
79
+ <grantcodes-form-field label="Radio number 1" direction="horizontal">
78
80
  <input type="radio" name="radio-group" value="1" />
79
81
  </grantcodes-form-field>
80
- <grantcodes-form-field label="Radio number 2">
82
+ <grantcodes-form-field label="Radio number 2" direction="horizontal">
81
83
  <input type="radio" name="radio-group" value="2" />
82
84
  </grantcodes-form-field>
83
- <grantcodes-form-field label="Radio number 3">
85
+ <grantcodes-form-field label="Radio number 3" direction="horizontal">
84
86
  <input type="radio" name="radio-group" value="3" />
85
87
  </grantcodes-form-field>
86
88
  `,
@@ -90,13 +92,13 @@ export const FormFieldWithRadioGroup = {
90
92
  export const FormFieldWithCheckboxGroup = {
91
93
  args: {
92
94
  slot: html`
93
- <grantcodes-form-field label="Checkbox number 1"
95
+ <grantcodes-form-field label="Checkbox number 1" direction="horizontal"
94
96
  ><input type="checkbox"
95
97
  /></grantcodes-form-field>
96
- <grantcodes-form-field label="Checkbox number 2"
98
+ <grantcodes-form-field label="Checkbox number 2" direction="horizontal"
97
99
  ><input type="checkbox"
98
100
  /></grantcodes-form-field>
99
- <grantcodes-form-field label="Checkbox number 3"
101
+ <grantcodes-form-field label="Checkbox number 3" direction="horizontal"
100
102
  ><input type="checkbox"
101
103
  /></grantcodes-form-field>
102
104
  `,