@kubex/zinc 1.1.58 → 1.1.60

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.
@@ -416,6 +416,36 @@ Form navigation works with all Zinc form controls including selects, radios, che
416
416
  **Note:** While you can navigate TO textareas and search inputs using Enter, pressing Enter while inside these controls uses the Enter key for their own functionality (new lines and search triggers, respectively). Use Tab to navigate away from these controls.
417
417
  :::
418
418
 
419
+ ### Trigger Submit
420
+
421
+ Add the `trigger-submit` attribute to make pressing Enter in the input always submit the surrounding form. Inputs already submit on Enter by default, so this is only needed on forms using `data-enter-navigation`, where Enter would otherwise move focus to the next field.
422
+
423
+ ```html:preview
424
+ <form class="trigger-submit-form" data-enter-navigation>
425
+ <zn-input name="firstName" label="First Name" required></zn-input>
426
+ <br />
427
+ <zn-input name="search" label="Quick Search (Enter submits)" trigger-submit></zn-input>
428
+ <br />
429
+ <zn-button type="submit" variant="primary">Submit</zn-button>
430
+ </form>
431
+
432
+ <script type="module">
433
+ const form = document.querySelector('.trigger-submit-form');
434
+
435
+ await Promise.all([
436
+ customElements.whenDefined('zn-button'),
437
+ customElements.whenDefined('zn-input')
438
+ ]).then(() => {
439
+ form.addEventListener('submit', (e) => {
440
+ e.preventDefault();
441
+ const formData = new FormData(form);
442
+ const data = Object.fromEntries(formData);
443
+ alert('Form submitted!\n\n' + JSON.stringify(data, null, 2));
444
+ });
445
+ });
446
+ </script>
447
+ ```
448
+
419
449
  ### Customizing Label Position
420
450
 
421
451
  Use [CSS parts](#css-parts) to customize the way form controls are drawn. This example uses CSS grid to position the
@@ -62,6 +62,68 @@ Use the `label` attribute to add a form control label above the toggle.
62
62
  <zn-toggle label="User Preferences">Enable email notifications</zn-toggle>
63
63
  ```
64
64
 
65
+ ### Description
66
+
67
+ Use the `description` attribute to add a description under the label. If you need to display HTML, use the `description` slot instead.
68
+
69
+ ```html:preview
70
+ <zn-toggle label="Email notifications" description="Receive an email whenever someone mentions you."></zn-toggle>
71
+ ```
72
+
73
+ The description stays under the label at every `label-position`, and can be combined with `help-text`, which always appears below the toggle.
74
+
75
+ ```html:preview
76
+ <zn-toggle
77
+ label="Top label (default)"
78
+ description="Receive an email whenever someone mentions you."
79
+ help-text="You can change this at any time"></zn-toggle>
80
+ <br />
81
+ <zn-toggle
82
+ label="Left label"
83
+ label-position="left"
84
+ description="Receive an email whenever someone mentions you."
85
+ help-text="You can change this at any time"></zn-toggle>
86
+ <br />
87
+ <zn-toggle
88
+ label="Right label"
89
+ label-position="right"
90
+ description="Receive an email whenever someone mentions you."
91
+ help-text="You can change this at any time"></zn-toggle>
92
+ ```
93
+
94
+ ### Label Position
95
+
96
+ Use the `label-position` attribute to control where the label sits relative to the toggle. Available positions are `top` (default), `left`, and `right`.
97
+
98
+ With `left`, the label sits at the start of the row and the toggle is pushed to the far right — useful for settings lists. With `right`, the layout is mirrored.
99
+
100
+ ```html:preview
101
+ <zn-toggle label="Top label (default)"></zn-toggle>
102
+ <br />
103
+ <zn-toggle label="Left label" label-position="left"></zn-toggle>
104
+ <br />
105
+ <zn-toggle label="Right label" label-position="right"></zn-toggle>
106
+ ```
107
+
108
+ Combine `label-position` with the `inline` attribute to keep the label and toggle adjacent instead of spread across the full width.
109
+
110
+ ```html:preview
111
+ <zn-toggle label="Inline left" label-position="left" inline></zn-toggle>
112
+ <br />
113
+ <zn-toggle label="Inline right" label-position="right" inline></zn-toggle>
114
+ ```
115
+
116
+ A settings list using `label-position="left"`:
117
+
118
+ ```html:preview
119
+ <div style="max-width: 320px;">
120
+ <zn-toggle label="Apple" label-position="left"></zn-toggle>
121
+ <zn-toggle label="Windows" label-position="left"></zn-toggle>
122
+ <zn-toggle label="Android" label-position="left"></zn-toggle>
123
+ <zn-toggle label="Blackberry" label-position="left" checked></zn-toggle>
124
+ </div>
125
+ ```
126
+
65
127
  ### On/Off Text with Tooltip
66
128
 
67
129
  Use the `on-text` and `off-text` attributes to display tooltips that show when the toggle is in the on or off state. This provides additional context for the toggle's current state.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.58",
3
+ "version": "1.1.60",
4
4
  "description": "A collection of web components for building web applications based off of @shoelace-style/Shoelace",
5
5
  "keywords": [
6
6
  "web components",
@@ -212,6 +212,12 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
212
212
  */
213
213
  @property() inputmode: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
214
214
 
215
+ /**
216
+ * When enabled, pressing enter will always submit the surrounding form, even when the form uses
217
+ * enter-navigation to move between fields.
218
+ */
219
+ @property({type: Boolean, attribute: 'trigger-submit'}) triggerSubmit = false;
220
+
215
221
  //
216
222
  // NOTE: We use an in-memory input for these getters/setters instead of the one in the template because the properties
217
223
  // can be set before the component is rendered.
@@ -561,9 +567,10 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
561
567
  // When using a Input Method Editor (IME), pressing enter will cause the form to submit unexpectedly. One way
562
568
  // to check for this is to look at event.isComposing, which will be true when the IME is open.
563
569
  if (!event.defaultPrevented && !event.isComposing) {
564
- // Check if form has navigation enabled - if so, let FormNavigationController handle it
570
+ // Check if form has navigation enabled - if so, let FormNavigationController handle it,
571
+ // unless trigger-submit is set which always submits on enter
565
572
  const form = this.formControlController.getForm();
566
- if (form?.hasAttribute('enter-navigation') || form?.hasAttribute('data-enter-navigation')) {
573
+ if (!this.triggerSubmit && (form?.hasAttribute('enter-navigation') || form?.hasAttribute('data-enter-navigation'))) {
567
574
  return; // Let FormNavigationController handle it
568
575
  }
569
576
 
@@ -42,6 +42,61 @@ describe('<zn-input>', () => {
42
42
  });
43
43
  });
44
44
 
45
+ describe('trigger-submit', () => {
46
+ const pressEnter = (el: ZnInput) => {
47
+ el.input.dispatchEvent(new KeyboardEvent('keydown', {
48
+ key: 'Enter',
49
+ bubbles: true,
50
+ composed: true,
51
+ cancelable: true
52
+ }));
53
+ };
54
+
55
+ it('submits an enter-navigation form on enter when trigger-submit is set', async () => {
56
+ const form = await fixture<HTMLFormElement>(html`
57
+ <form data-enter-navigation>
58
+ <zn-input name="a" trigger-submit></zn-input>
59
+ <zn-input name="b"></zn-input>
60
+ </form>
61
+ `);
62
+ const el = form.querySelector('zn-input')!;
63
+ await el.updateComplete;
64
+
65
+ let submitted = false;
66
+ form.addEventListener('submit', e => {
67
+ e.preventDefault();
68
+ submitted = true;
69
+ });
70
+
71
+ el.focus();
72
+ pressEnter(el);
73
+
74
+ expect(submitted).to.be.true;
75
+ });
76
+
77
+ it('does not submit an enter-navigation form on enter without trigger-submit', async () => {
78
+ const form = await fixture<HTMLFormElement>(html`
79
+ <form data-enter-navigation>
80
+ <zn-input name="a"></zn-input>
81
+ <zn-input name="b"></zn-input>
82
+ </form>
83
+ `);
84
+ const el = form.querySelector('zn-input')!;
85
+ await el.updateComplete;
86
+
87
+ let submitted = false;
88
+ form.addEventListener('submit', e => {
89
+ e.preventDefault();
90
+ submitted = true;
91
+ });
92
+
93
+ el.focus();
94
+ pressEnter(el);
95
+
96
+ expect(submitted).to.be.false;
97
+ });
98
+ });
99
+
45
100
  describe('persisted state', () => {
46
101
  const formStoreKey = 'input-test-form-state';
47
102
  const elementStoreKey = 'input-test-element-state';
@@ -2,6 +2,7 @@ import { classMap } from "lit/directives/class-map.js";
2
2
  import { type CSSResultGroup, html, type PropertyValues, unsafeCSS } from 'lit';
3
3
  import { defaultValue } from "../../internal/default-value";
4
4
  import { FormControlController } from "../../internal/form";
5
+ import { HasSlotController } from "../../internal/slot";
5
6
  import { ifDefined } from "lit/directives/if-defined.js";
6
7
  import { live } from "lit/directives/live.js";
7
8
  import { property, query, state } from 'lit/decorators.js';
@@ -20,15 +21,20 @@ import styles from './toggle.scss';
20
21
  * @event zn-input - Emitted when the toggle receives input.
21
22
  *
22
23
  * @slot - The toggle's label.
24
+ * @slot description - A description of the toggle's label. Alternatively, you can use the `description` attribute.
25
+ * @slot help-text - Text that describes how to use the toggle. Alternatively, you can use the `help-text` attribute.
23
26
  *
24
27
  * @csspart base - The component's base wrapper containing the toggle switch.
25
28
  * @csspart control - The toggle switch control (the circular button that slides).
29
+ * @csspart description - The container that wraps the toggle's description.
26
30
  *
27
31
  * @cssproperty --zn-toggle-margin - The margin around the toggle switch. Defaults to `8px 0`.
28
32
  */
29
33
  export default class ZnToggle extends ZincElement implements ZincFormControl {
30
34
  static styles: CSSResultGroup = unsafeCSS(styles);
31
35
 
36
+ private readonly hasSlotController = new HasSlotController(this, 'help-text', 'description');
37
+
32
38
  private readonly formControlController = new FormControlController(this, {
33
39
  value: (control: ZnToggle) => (control.checked ? control.value || 'on' : undefined),
34
40
  defaultValue: (control: ZnToggle) => control.defaultChecked,
@@ -69,6 +75,11 @@ export default class ZnToggle extends ZincElement implements ZincFormControl {
69
75
 
70
76
  @property() label: string = '';
71
77
 
78
+ /** The toggle's description, displayed under the label. If you need to display HTML, use the `description` slot instead. */
79
+ @property() description: string = '';
80
+
81
+ @property({ attribute: 'label-position', reflect: true }) labelPosition: 'top' | 'left' | 'right' = 'top';
82
+
72
83
  @property({ type: Boolean }) inline: boolean = false;
73
84
 
74
85
  get validity() {
@@ -167,6 +178,8 @@ export default class ZnToggle extends ZincElement implements ZincFormControl {
167
178
 
168
179
  const tooltipContent = this.checked ? this.onText : this.offText;
169
180
  const showTooltip = !!tooltipContent;
181
+ const hasHelpText = this.helpText ? true : this.hasSlotController.test('help-text');
182
+ const hasDescription = this.description ? true : this.hasSlotController.test('description');
170
183
 
171
184
  const toggle = html`
172
185
  <div class="switch__input-wrapper" part="base">
@@ -182,7 +195,7 @@ export default class ZnToggle extends ZincElement implements ZincFormControl {
182
195
  .required=${this.required}
183
196
  role="switch"
184
197
  aria-checked=${this.checked ? 'true' : 'false'}
185
- aria-describedby="help-text"
198
+ aria-describedby="description help-text"
186
199
  @click=${this.handleClick}
187
200
  @input=${this.handleInput}
188
201
  @invalid=${this.handleInvalid}
@@ -195,22 +208,45 @@ export default class ZnToggle extends ZincElement implements ZincFormControl {
195
208
 
196
209
  return html`
197
210
  <div class="${classMap({
211
+ 'form-control': true,
212
+ 'form-control--small': this.size === 'small',
213
+ 'form-control--medium': this.size === 'medium',
214
+ 'form-control--large': this.size === 'large',
215
+ 'form-control--has-help-text': hasHelpText,
198
216
  'switch__wrapper': true,
217
+ 'switch__wrapper--has-description': hasDescription,
199
218
  'switch__wrapper--disabled': this.disabled,
200
219
  'switch__wrapper--has-focus': this.hasFocus,
201
220
  'switch__wrapper--small': this.size === 'small',
202
221
  'switch__wrapper--medium': this.size === 'medium',
203
222
  'switch__wrapper--large': this.size === 'large',
204
- 'switch__wrapper--inline': this.inline
223
+ 'switch__wrapper--inline': this.inline,
224
+ 'switch__wrapper--label-left': this.labelPosition === 'left',
225
+ 'switch__wrapper--label-right': this.labelPosition === 'right'
205
226
  })}">
206
227
  <label>
207
- ${this.label ? html`<p class="switch-label">${this.label}</p>` : ''}
228
+ ${this.label || hasDescription ? html`
229
+ <div class="switch__label-wrapper">
230
+ ${this.label ? html`<p class="switch-label">${this.label}</p>` : ''}
231
+ <div part="description"
232
+ id="description"
233
+ class="switch__description"
234
+ aria-hidden=${hasDescription ? 'false' : 'true'}>
235
+ <slot name="description">${this.description}</slot>
236
+ </div>
237
+ </div>` : ''}
208
238
  ${!showTooltip ? html`
209
239
  ${toggle}` : html`
210
240
  <zn-tooltip content="${tooltipContent}">
211
241
  ${toggle}
212
242
  </zn-tooltip>`}
213
243
  </label>
244
+ <div part="form-control-help-text"
245
+ id="help-text"
246
+ class="form-control__help-text"
247
+ aria-hidden=${hasHelpText ? 'false' : 'true'}>
248
+ <slot name="help-text">${this.helpText}</slot>
249
+ </div>
214
250
  </div>`;
215
251
  }
216
252
  }
@@ -1,11 +1,22 @@
1
1
  @use "../../wc";
2
+ @use "../../form-control";
2
3
 
3
4
  :host {
4
5
  --zn-toggle-margin: 8px 0;
6
+ display: block;
5
7
  }
6
8
 
7
9
  .switch__wrapper {
8
- display: inline-flex;
10
+ display: flex;
11
+ flex-direction: column;
12
+
13
+ label {
14
+ flex: 1;
15
+ display: flex;
16
+ flex-direction: column;
17
+ align-items: flex-start;
18
+ cursor: pointer;
19
+ }
9
20
  }
10
21
 
11
22
  .switch__input-wrapper {
@@ -47,7 +58,33 @@
47
58
  }
48
59
 
49
60
  .switch-label {
50
- font-size: 90%;
61
+ margin: 0;
62
+ color: var(--zn-input-label-color);
63
+ font-size: var(--zn-input-label-font-size-medium);
64
+ font-weight: var(--zn-input-label-font-weight);
65
+ line-height: var(--zn-input-label-line-height);
66
+ }
67
+
68
+ .switch__wrapper--small .switch-label {
69
+ font-size: var(--zn-input-label-font-size-small);
70
+ }
71
+
72
+ .switch__description {
73
+ display: none;
74
+ }
75
+
76
+ .switch__wrapper--has-description .switch__description {
77
+ display: block;
78
+ color: var(--zn-input-help-text-color);
79
+ font-size: var(--zn-input-font-size-small);
80
+ }
81
+
82
+ .switch__wrapper--small .switch__description {
83
+ font-size: var(--zn-font-size-x-small);
84
+ }
85
+
86
+ .switch__wrapper--large .switch-label {
87
+ font-size: var(--zn-input-label-font-size-large);
51
88
  }
52
89
 
53
90
  .switch__control {
@@ -91,10 +128,32 @@
91
128
  }
92
129
  }
93
130
 
131
+ // Label position
132
+ .switch__wrapper--label-left label,
133
+ .switch__wrapper--label-right label {
134
+ align-items: center;
135
+ justify-content: space-between;
136
+ gap: 8px;
137
+ }
138
+
139
+ .switch__wrapper--label-left label {
140
+ flex-direction: row;
141
+ }
142
+
143
+ .switch__wrapper--label-right label {
144
+ flex-direction: row-reverse;
145
+ }
146
+
94
147
  // Support inline label
148
+ :host([inline]) {
149
+ display: inline-block;
150
+ }
151
+
95
152
  .switch__wrapper--inline label {
153
+ flex: none;
154
+ flex-direction: row;
96
155
  align-items: center;
97
- display: flex;
156
+ justify-content: flex-start;
98
157
  gap: 8px;
99
158
 
100
159
  .switch__input {
@@ -102,3 +161,7 @@
102
161
  margin-inline-end: 1px;
103
162
  }
104
163
  }
164
+
165
+ .switch__wrapper--inline.switch__wrapper--label-right label {
166
+ flex-direction: row-reverse;
167
+ }
@@ -7,4 +7,15 @@ describe('<zn-toggle>', () => {
7
7
 
8
8
  expect(el).to.exist;
9
9
  });
10
+
11
+ it('should render the description under the label', async () => {
12
+ const el = await fixture<HTMLElement>(html`
13
+ <zn-toggle label="Email notifications" description="Receive an email when mentioned."></zn-toggle> `);
14
+
15
+ const label = el.shadowRoot!.querySelector('.switch-label')!;
16
+ const description = el.shadowRoot!.querySelector('[part="description"]')!;
17
+
18
+ expect(description.textContent).to.include('Receive an email when mentioned.');
19
+ expect(label.nextElementSibling).to.equal(description);
20
+ });
10
21
  });
@@ -46,6 +46,11 @@ export class FormNavigationController {
46
46
  return;
47
47
  }
48
48
 
49
+ // Controls with trigger-submit handle submitting the form themselves
50
+ if (target.hasAttribute('trigger-submit')) {
51
+ return;
52
+ }
53
+
49
54
  // Check if we're in a group (radio-group or checkbox-group)
50
55
  const group = this.findParentGroup(target);
51
56
  if (group) {