@kubex/zinc 1.1.98 → 1.1.99

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.
Files changed (56) hide show
  1. package/AGENTS.md +5 -0
  2. package/CLAUDE.md +5 -0
  3. package/dist/custom-elements.json +2930 -2072
  4. package/dist/vscode.html-custom-data.json +198 -59
  5. package/dist/web-types.json +501 -207
  6. package/dist/zn.d.ts +203 -9
  7. package/dist/zn.min.js +343 -297
  8. package/docs/pages/components/background.md +143 -0
  9. package/docs/pages/components/checkbox-group.md +13 -1
  10. package/docs/pages/components/checkbox.md +59 -1
  11. package/docs/pages/components/radio-group.md +12 -0
  12. package/docs/pages/components/radio.md +188 -0
  13. package/docs/pages/components/remarkd-editor.md +22 -0
  14. package/docs/pages/components/scroll-container.md +14 -15
  15. package/package.json +1 -1
  16. package/src/components/background/background.component.ts +111 -0
  17. package/src/components/background/background.scss +236 -0
  18. package/src/components/background/background.test.ts +120 -0
  19. package/src/components/background/index.ts +12 -0
  20. package/src/components/checkbox/checkbox.component.ts +62 -6
  21. package/src/components/checkbox/checkbox.scss +1 -0
  22. package/src/components/checkbox/checkbox.test.ts +56 -0
  23. package/src/components/checkbox-group/checkbox-group.component.ts +15 -0
  24. package/src/components/checkbox-group/checkbox-group.scss +23 -5
  25. package/src/components/checkbox-group/checkbox-group.test.ts +15 -0
  26. package/src/components/confirm/confirm.component.ts +3 -3
  27. package/src/components/confirm/confirm.test.ts +15 -0
  28. package/src/components/dialog/dialog.component.ts +3 -2
  29. package/src/components/editor/editor.component.ts +7 -6
  30. package/src/components/header/header.scss +1 -1
  31. package/src/components/navbar/navbar.scss +11 -0
  32. package/src/components/radio/radio.component.ts +63 -7
  33. package/src/components/radio/radio.scss +1 -0
  34. package/src/components/radio/radio.test.ts +56 -0
  35. package/src/components/radio-group/radio-group.component.ts +16 -3
  36. package/src/components/radio-group/radio-group.scss +23 -4
  37. package/src/components/radio-group/radio-group.test.ts +15 -0
  38. package/src/components/remarkd-editor/remarkd-editor.component.ts +118 -5
  39. package/src/components/remarkd-editor/remarkd-editor.scss +8 -1
  40. package/src/components/remarkd-editor/remarkd-editor.test.ts +96 -0
  41. package/src/components/scroll-container/scroll-container.component.ts +15 -1
  42. package/src/components/scroll-container/scroll-container.scss +4 -2
  43. package/src/components/scroll-container/scroll-container.test.ts +13 -0
  44. package/src/components/slideout/slideout.component.ts +3 -2
  45. package/src/components/thumbnail-group/thumbnail-group.scss +0 -1
  46. package/src/components/tile/tile.scss +4 -2
  47. package/src/components/translation-group/translation-group.test.ts +22 -0
  48. package/src/components/translations/translations.component.ts +18 -3
  49. package/src/form-control.scss +3 -1
  50. package/src/internal/conditional.ts +2 -2
  51. package/src/internal/form.ts +2 -1
  52. package/src/internal/selection-card.ts +19 -0
  53. package/src/selection-card.scss +187 -0
  54. package/src/utilities/query.test.ts +18 -1
  55. package/src/utilities/query.ts +8 -0
  56. package/src/zinc.ts +1 -0
@@ -7,4 +7,60 @@ describe('<zn-checkbox>', () => {
7
7
 
8
8
  expect(el).to.exist;
9
9
  });
10
+
11
+ it('renders card content and a built-in image', async () => {
12
+ const el = await fixture(html`
13
+ <zn-checkbox
14
+ contained
15
+ card-title="Payroll"
16
+ description="Salary and pay schedule tools."
17
+ src="payroll.svg"
18
+ image-alt="Payroll illustration">
19
+ </zn-checkbox>
20
+ `);
21
+
22
+ const image = el.shadowRoot!.querySelector('[part="image"]')!;
23
+ expect(image.getAttribute('src')).to.equal('payroll.svg');
24
+ expect(image.getAttribute('alt')).to.equal('Payroll illustration');
25
+ expect(el.shadowRoot!.querySelector('[part="label"]')!.textContent).to.include('Payroll');
26
+ });
27
+
28
+ it('renders card-title when formatting whitespace is present in the default slot', async () => {
29
+ const el = await fixture(html`
30
+ <zn-checkbox contained card-title="Benefits">
31
+ <zn-icon slot="image" src="featured_seasonal_and_gifts"></zn-icon>
32
+ </zn-checkbox>
33
+ `);
34
+
35
+ expect(el.shadowRoot!.querySelector('[part="card-title"]')!.textContent).to.include('Benefits');
36
+ });
37
+
38
+ it('positions card images and controls', async () => {
39
+ const el = await fixture(html`
40
+ <zn-checkbox contained image-position="right" control-position="top-left">
41
+ Benefits
42
+ <zn-icon slot="image" src="featured_seasonal_and_gifts"></zn-icon>
43
+ </zn-checkbox>
44
+ `);
45
+ const base = el.shadowRoot!.querySelector('[part="base"]')!;
46
+
47
+ expect(base).to.have.class('selection-card--image-right');
48
+ expect(base).to.have.class('selection-card--control-top-left');
49
+ expect(el.shadowRoot!.querySelector('slot[name="image"]')).to.exist;
50
+ });
51
+
52
+ it('leaves a plain contained checkbox without card styles', async () => {
53
+ const el = await fixture(html`<zn-checkbox contained description="Supporting text">Payroll</zn-checkbox>`);
54
+
55
+ expect(el.shadowRoot!.querySelector('[part="base"]')).not.to.have.class('selection-card');
56
+ expect(el.shadowRoot!.querySelector('[part="image-container"]')).to.be.null;
57
+ });
58
+
59
+ it('can hide the visual control without removing the native input', async () => {
60
+ const el = await fixture(html`<zn-checkbox contained control-position="none">Payroll</zn-checkbox>`);
61
+
62
+ expect(el.shadowRoot!.querySelector('input[type="checkbox"]')).to.exist;
63
+ expect(el.shadowRoot!.querySelector('[part~="control"]')).to.exist;
64
+ expect(el.shadowRoot!.querySelector('[part="base"]')).to.have.class('selection-card--control-none');
65
+ });
10
66
  });
@@ -34,6 +34,8 @@ import styles from './checkbox-group.scss';
34
34
  * @csspart form-control-label - The label's wrapper.
35
35
  * @csspart form-control-input - The input's wrapper.
36
36
  * @csspart form-control-help-text - The help text's wrapper.
37
+ *
38
+ * @cssproperty --zn-checkbox-group-column-width - Minimum column width used by the horizontal contained grid, or the card basis when `wrap` is set.
37
39
  */
38
40
  export default class ZnCheckboxGroup extends ZincElement implements ZincFormControl {
39
41
  static styles: CSSResultGroup = unsafeCSS(styles);
@@ -75,6 +77,15 @@ export default class ZnCheckboxGroup extends ZincElement implements ZincFormCont
75
77
  /** The checkbox group's style. Changes the group's style from the default (plain) style to the 'contained' style. This style will be applied to all child checkboxes. */
76
78
  @property({type: Boolean, reflect: true}) contained = false;
77
79
 
80
+ /** Squares off the corners of every contained checkbox in the group, which are rounded by default. */
81
+ @property({type: Boolean, reflect: true}) square = false;
82
+
83
+ /**
84
+ * Lays horizontal contained checkboxes out as a wrapping row instead of an equal-width grid, so each one sizes from
85
+ * `--zn-checkbox-group-column-width` (set it to `auto` to size from content) and wraps onto a new line when needed.
86
+ */
87
+ @property({type: Boolean, reflect: true}) wrap = false;
88
+
78
89
  /**
79
90
  * By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
80
91
  * to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
@@ -158,6 +169,10 @@ export default class ZnCheckboxGroup extends ZincElement implements ZincFormCont
158
169
  checkbox.size = this.size;
159
170
  checkbox.horizontal = this.horizontal;
160
171
 
172
+ if (this.square) {
173
+ checkbox.square = true;
174
+ }
175
+
161
176
  // Add class to checkboxes in a Checkbox Group so that we can style them without using :slotted
162
177
  checkbox.classList.add('groupedCheckbox');
163
178
 
@@ -8,8 +8,9 @@
8
8
 
9
9
  :host([horizontal]) .form-control-input {
10
10
  display: flex;
11
- column-gap: var(--zn-spacing-2x-large);
12
11
  flex-wrap: wrap;
12
+ column-gap: var(--zn-spacing-2x-large);
13
+ row-gap: var(--zn-spacing-small);
13
14
  }
14
15
 
15
16
  ::slotted(zn-checkbox[horizontal]) {
@@ -17,15 +18,16 @@
17
18
  min-width: 100px;
18
19
  }
19
20
 
20
- :host([contained]) .form-control-input {
21
+ /* The label supplies the gap above the options, so a group without one starts flush. */
22
+ :host([contained]) .form-control--has-label .form-control-input {
21
23
  margin-top: var(--zn-spacing-medium);
22
24
  }
23
25
 
24
26
  :host([horizontal][contained]) .form-control-input {
25
- margin-top: var(--zn-spacing-medium);
26
27
  display: grid;
27
- grid-template-columns: repeat(auto-fit, minmax(152px, 1fr));
28
- gap: var(--zn-spacing-x-small);
28
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--zn-checkbox-group-column-width, 152px)), 1fr));
29
+ align-items: stretch;
30
+ gap: var(--zn-spacing-small);
29
31
  }
30
32
 
31
33
  ::slotted(zn-checkbox) {
@@ -42,6 +44,22 @@
42
44
  height: 100%;
43
45
  }
44
46
 
47
+ /* `wrap` swaps the equal-width grid for a flex row: cards start from their basis and wrap onto new lines. */
48
+ :host([horizontal][contained][wrap]) .form-control-input {
49
+ display: flex;
50
+ flex-wrap: wrap;
51
+ align-items: stretch;
52
+ gap: var(--zn-spacing-x-small);
53
+ }
54
+
55
+ :host([wrap]) ::slotted(zn-checkbox[horizontal][contained]) {
56
+ flex: 1 1 var(--zn-checkbox-group-column-width, 152px);
57
+ min-width: 0;
58
+
59
+ /* A percentage height would resolve against every line, not the card's own. */
60
+ height: auto;
61
+ }
62
+
45
63
 
46
64
  .form-control {
47
65
  position: relative;
@@ -7,4 +7,19 @@ describe('<zn-checkbox-group>', () => {
7
7
 
8
8
  expect(el).to.exist;
9
9
  });
10
+
11
+ it('propagates contained and square to its checkboxs', async () => {
12
+ const el = await fixture<HTMLElement>(html`
13
+ <zn-checkbox-group contained square>
14
+ <zn-checkbox value="one">One</zn-checkbox>
15
+ <zn-checkbox value="two">Two</zn-checkbox>
16
+ </zn-checkbox-group>
17
+ `);
18
+ await new Promise(resolve => requestAnimationFrame(resolve));
19
+
20
+ el.querySelectorAll('zn-checkbox').forEach(child => {
21
+ expect(child).to.have.attribute('contained');
22
+ expect(child).to.have.attribute('square');
23
+ });
24
+ });
10
25
  });
@@ -1,6 +1,6 @@
1
1
  import {classMap} from "lit/directives/class-map.js";
2
2
  import {type CSSResultGroup, html, nothing, type PropertyValues, unsafeCSS} from 'lit';
3
- import {deepQuerySelectorAll} from "../../utilities/query";
3
+ import {deepQuerySelectorAll, idSelector} from "../../utilities/query";
4
4
  import {HasSlotController} from "../../internal/slot";
5
5
  import {ifDefined} from "lit/directives/if-defined.js";
6
6
  import {property, query, state} from 'lit/decorators.js';
@@ -97,7 +97,7 @@ export default class ZnConfirm extends ZincElement {
97
97
  const trigger = this.hasSlotController.getSlot('trigger');
98
98
  trigger?.addEventListener('click', this.show);
99
99
  } else if (this.trigger) {
100
- const triggers = deepQuerySelectorAll('#' + this.trigger, this.parentElement as Element, '');
100
+ const triggers = deepQuerySelectorAll(idSelector(this.trigger), this.parentElement as Element, '');
101
101
  triggers.forEach((el: HTMLButtonElement) => {
102
102
  el.addEventListener('click', this.show);
103
103
  });
@@ -105,7 +105,7 @@ export default class ZnConfirm extends ZincElement {
105
105
  }
106
106
 
107
107
  updateTriggers() {
108
- const triggers = deepQuerySelectorAll('#' + this.trigger, this.parentElement as Element, '');
108
+ const triggers = deepQuerySelectorAll(idSelector(this.trigger), this.parentElement as Element, '');
109
109
  triggers.forEach((el: HTMLButtonElement) => {
110
110
  // if already has a click listener, remove it
111
111
  el.removeEventListener('click', this.show);
@@ -7,4 +7,19 @@ describe('<zn-confirm-modal>', () => {
7
7
 
8
8
  expect(el).to.exist;
9
9
  });
10
+
11
+ it('connects a trigger whose id contains CSS syntax characters', async () => {
12
+ const id = 'delete-enum-cloud_hosting_&_infrastructure';
13
+ const el = await fixture<HTMLElement>(html`
14
+ <div>
15
+ <button id="${id}">Delete</button>
16
+ <zn-confirm trigger="${id}" caption="Delete"></zn-confirm>
17
+ </div>
18
+ `);
19
+ const confirm = el.querySelector<HTMLElement & {updateComplete: Promise<void>}>('zn-confirm')!;
20
+ await confirm.updateComplete;
21
+
22
+ // An unescaped `#id` selector throws a SyntaxError here, leaving the component unrendered.
23
+ expect(confirm.shadowRoot!.querySelector('zn-dialog')).to.exist;
24
+ });
10
25
  });
@@ -1,6 +1,7 @@
1
1
  import {classMap} from "lit/directives/class-map.js";
2
2
  import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit';
3
3
  import {HasSlotController} from "../../internal/slot";
4
+ import {idSelector} from "../../utilities/query";
4
5
  import {ifDefined} from "lit/directives/if-defined.js";
5
6
  import {property, query} from 'lit/decorators.js';
6
7
  import {unlockBodyScrolling} from "../../internal/scroll";
@@ -105,7 +106,7 @@ export default class ZnDialog extends ZincElement {
105
106
  this.shadowRoot?.addEventListener('click', this.closeClickHandler);
106
107
 
107
108
  if (this.trigger) {
108
- const trigger = this.parentNode?.querySelector('#' + this.trigger);
109
+ const trigger = this.parentNode?.querySelector(idSelector(this.trigger));
109
110
  if (trigger) {
110
111
  trigger.addEventListener('click', () => this.show());
111
112
  }
@@ -132,7 +133,7 @@ export default class ZnDialog extends ZincElement {
132
133
  this.removeEventListener('click', this.closeClickHandler);
133
134
 
134
135
  if (this.trigger) {
135
- const trigger = this.parentElement?.querySelector('#' + this.trigger);
136
+ const trigger = this.parentElement?.querySelector(idSelector(this.trigger));
136
137
  if (trigger) {
137
138
  trigger.removeEventListener('click', () => this.show());
138
139
  }
@@ -1,5 +1,5 @@
1
1
  import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit';
2
- import {deepQuerySelectorAll} from "../../utilities/query";
2
+ import {deepQuerySelectorAll, idSelector} from "../../utilities/query";
3
3
  import {FormControlController} from '../../internal/form';
4
4
  import {on} from "../../utilities/on";
5
5
  import {property, query} from 'lit/decorators.js';
@@ -489,12 +489,13 @@ export default class ZnEditor extends ZincElement implements ZincFormControl {
489
489
 
490
490
  // Find which element contains the content to insert
491
491
  // Priority: currentTarget > selectedTarget > target > composedPath > querySelector
492
+ const contentSelector = idSelector(contentContainer);
492
493
  const contentElement: Element | null =
493
- (e.currentTarget instanceof Element && e.currentTarget.closest('#' + contentContainer)) ||
494
- (e.selectedTarget instanceof Element && e.selectedTarget.closest('#' + contentContainer)) ||
495
- (e.target instanceof Element && e.target.closest('#' + contentContainer)) ||
496
- ((e.composedPath()[0] as Element)?.closest('#' + contentContainer)) ||
497
- deepQuerySelectorAll(`#${contentContainer}`, document.documentElement, '')[0];
494
+ (e.currentTarget instanceof Element && e.currentTarget.closest(contentSelector)) ||
495
+ (e.selectedTarget instanceof Element && e.selectedTarget.closest(contentSelector)) ||
496
+ (e.target instanceof Element && e.target.closest(contentSelector)) ||
497
+ ((e.composedPath()[0] as Element)?.closest(contentSelector)) ||
498
+ deepQuerySelectorAll(contentSelector, document.documentElement, '')[0];
498
499
  if (!contentElement) return;
499
500
 
500
501
  let content = contentElement.textContent;
@@ -110,6 +110,7 @@
110
110
  width: 100%;
111
111
  max-width: 100%;
112
112
  height: fit-content;
113
+ min-height: var(--zn-input-height-medium, 36px);
113
114
  gap: 10px;
114
115
  margin: 0 auto;
115
116
  padding: var(--zn-base-gap);
@@ -226,7 +227,6 @@
226
227
  slot[name="actions"] {
227
228
  justify-content: end;
228
229
  flex-wrap: nowrap;
229
- height: var(--zn-icon-size, 20px);
230
230
  }
231
231
 
232
232
  slot[name="actions"]::slotted(zn-button-menu) {
@@ -303,6 +303,17 @@ ul.navbar.navbar--stacked.navbar--icon-bar {
303
303
  }
304
304
  }
305
305
 
306
+ // Icon-bar items are standalone pills, not tabs — the tab-strip rule above
307
+ // leaves them with a top+right border only, which reads as an unfinished box
308
+ :host([icon-bar]:not([stacked])) {
309
+ ul.navbar {
310
+ > li {
311
+ border-width: 1px;
312
+ border-radius: 6px;
313
+ }
314
+ }
315
+ }
316
+
306
317
  :host {
307
318
  display: block;
308
319
  width: 100%;
@@ -9,6 +9,7 @@ import {property, query, state} from 'lit/decorators.js';
9
9
  import {watch} from '../../internal/watch';
10
10
  import ZincElement from '../../internal/zinc-element';
11
11
  import ZnIcon from "../icon";
12
+ import type {SelectionCardControlPosition, SelectionCardImagePosition} from '../../internal/selection-card';
12
13
  import type {ZincFormControl} from '../../internal/zinc-element';
13
14
 
14
15
  import styles from './radio.scss';
@@ -22,6 +23,7 @@ import styles from './radio.scss';
22
23
  * @dependency zn-icon
23
24
  *
24
25
  * @slot - The radio's label.
26
+ * @slot image - Replaces the built-in image used by contained radios.
25
27
  * @slot description - A description of the radio's label. Serves as help text for a radio item. Alternatively, you can use the `description` attribute.
26
28
  * @slot selected-content - Use to nest rich content (like an input) inside a selected radio item. Use only with the contained style.
27
29
  *
@@ -35,9 +37,22 @@ import styles from './radio.scss';
35
37
  * @csspart control - The square container that wraps the radio's checked state.
36
38
  * @csspart control--checked - Matches the control part when the radio is checked.
37
39
  * @csspart checked-icon - The checked icon, an `<zn-icon>` element.
40
+ * @csspart image-container - The wrapper around the built-in image or image slot.
41
+ * @csspart image - The built-in image.
42
+ * @csspart card-title - The title inside a selection card.
38
43
  * @csspart label - The container that wraps the radio's label.
39
44
  * @csspart description - The container that wraps the radio's description.
40
45
  * @csspart selected-content - The container that wraps optional content that appears when a radio is checked.
46
+ *
47
+ * @cssproperty --zn-selection-card-image-width - Width of the built-in card image.
48
+ * @cssproperty --zn-selection-card-image-height - Height of the built-in card image.
49
+ * @cssproperty --zn-selection-card-min-height - Minimum height of a contained radio with an image.
50
+ * @cssproperty --zn-selection-card-content-min-width - Width the card text keeps before it wraps below the image.
51
+ * @cssproperty --zn-selection-card-title-font-size - Font size of a selection card title.
52
+ * @cssproperty --zn-selection-card-padding - Equal inset around the contents of a selection card.
53
+ * @cssproperty --zn-selection-card-gap - Space between the image, title, and indicator gutter.
54
+ * @cssproperty --zn-selection-card-control-offset - Distance between a positioned control and the card edge.
55
+ * @cssproperty --zn-selection-card-border-radius - Corner radius of a contained container.
41
56
  */
42
57
  export default class ZnRadio extends ZincElement implements ZincFormControl {
43
58
  static styles: CSSResultGroup = unsafeCSS(styles);
@@ -48,7 +63,7 @@ export default class ZnRadio extends ZincElement implements ZincFormControl {
48
63
  defaultValue: (control: ZnRadio) => control.defaultChecked,
49
64
  setValue: (control: ZnRadio, checked: boolean) => (control.checked = checked)
50
65
  });
51
- private readonly hasSlotController = new HasSlotController(this, 'description');
66
+ private readonly hasSlotController = new HasSlotController(this, '[default]', 'description', 'image');
52
67
 
53
68
  @query('input[type="radio"]') input: HTMLInputElement;
54
69
 
@@ -62,6 +77,9 @@ export default class ZnRadio extends ZincElement implements ZincFormControl {
62
77
  /** The current value of the radio, submitted as a name/value pair with form data. */
63
78
  @property() value: string;
64
79
 
80
+ /** Title rendered inside the card. The default slot takes precedence when provided. */
81
+ @property({attribute: 'card-title'}) cardTitle = '';
82
+
65
83
  /** The radio's size. */
66
84
  @property({reflect: true}) size: 'small' | 'medium' | 'large' = 'medium';
67
85
 
@@ -74,6 +92,24 @@ export default class ZnRadio extends ZincElement implements ZincFormControl {
74
92
  /** Draws a container around the radio. */
75
93
  @property({type: Boolean, reflect: true}) contained = false;
76
94
 
95
+ /** Squares off the corners of the container drawn by `contained`, which is rounded by default. */
96
+ @property({type: Boolean, reflect: true}) square = false;
97
+
98
+ /** URL for the image shown in a contained radio. */
99
+ @property() src = '';
100
+
101
+ /** Accessible text for the built-in image. Leave empty when the image is decorative. */
102
+ @property({attribute: 'image-alt'}) imageAlt = '';
103
+
104
+ /** Places the image above, beside, or below the radio's text. Requires `contained`. */
105
+ @property({attribute: 'image-position', reflect: true}) imagePosition: SelectionCardImagePosition = 'left';
106
+
107
+ /**
108
+ * Places the radio indicator within a contained card. Use `none` to hide the indicator so the card itself
109
+ * shows the selected state. Requires `contained`.
110
+ */
111
+ @property({attribute: 'control-position', reflect: true}) controlPosition: SelectionCardControlPosition = 'start';
112
+
77
113
  /** Applies styles relevant to radios in a horizontal layout. */
78
114
  @property({type: Boolean, reflect: true}) horizontal = false;
79
115
 
@@ -198,6 +234,9 @@ export default class ZnRadio extends ZincElement implements ZincFormControl {
198
234
  render() {
199
235
  const hasDescriptionSlot = this.hasSlotController.test('description');
200
236
  const hasDescription = this.description ? true : hasDescriptionSlot;
237
+ const hasImage = this.contained && (Boolean(this.src) || this.hasSlotController.test('image'));
238
+ const hasDefaultSlot = this.hasSlotController.test('[default]');
239
+ const isCard = this.contained && (hasImage || Boolean(this.cardTitle) || this.controlPosition !== 'start');
201
240
  const hasLabelSlot = this.hasSlotController.test('label');
202
241
  const hasLabelTooltip = this.hasSlotController.test('label-tooltip');
203
242
  const hasLabel = this.label || hasLabelSlot;
@@ -248,7 +287,11 @@ export default class ZnRadio extends ZincElement implements ZincFormControl {
248
287
  'radio--medium': this.size === 'medium',
249
288
  'radio--large': this.size === 'large',
250
289
  'radio--has-description': hasDescription,
251
- 'radio--has-selected-content': this.hasSlotController.test('selected-content')
290
+ 'radio--has-selected-content': this.hasSlotController.test('selected-content'),
291
+ 'selection-card': isCard,
292
+ 'selection-card--has-image': hasImage,
293
+ [`selection-card--image-${this.imagePosition}`]: hasImage,
294
+ [`selection-card--control-${this.controlPosition}`]: isCard && this.controlPosition !== 'start'
252
295
  })}>
253
296
 
254
297
  <input
@@ -261,7 +304,7 @@ export default class ZnRadio extends ZincElement implements ZincFormControl {
261
304
  .disabled=${this.disabled}
262
305
  .required=${this.required}
263
306
  aria-checked=${this.checked ? 'true' : 'false'}
264
- aria-describedby=${hasDescription ? '' : 'description'}
307
+ aria-describedby=${hasDescription ? 'description' : ''}
265
308
  @click=${this.handleClick}
266
309
  @input=${this.handleInput}
267
310
  @invalid=${this.handleInvalid}
@@ -270,16 +313,29 @@ export default class ZnRadio extends ZincElement implements ZincFormControl {
270
313
  />
271
314
  <span
272
315
  part="control${this.checked ? ' control--checked' : ''}"
273
- class="radio__control">
316
+ class="radio__control selection-card__control">
274
317
  ${this.checked
275
318
  ? html`
276
319
  <zn-icon part="checked-icon" size="18" class="radio__checked-icon"
277
320
  src="radio_button_checked"></zn-icon>`
278
321
  : ''}
279
- </span>
322
+ </span>
323
+
324
+ ${hasImage
325
+ ? html`
326
+ <span part="image-container" class="selection-card__image-container">
327
+ <slot name="image">
328
+ ${this.src
329
+ ? html`<img part="image" class="selection-card__image" src=${this.src} alt=${this.imageAlt}>`
330
+ : ''}
331
+ </slot>
332
+ </span>`
333
+ : ''}
280
334
 
281
- <div part="label" class="radio__label">
282
- <slot></slot>
335
+ <div part="label" class="radio__label selection-card__content">
336
+ <span part="card-title" class="selection-card__title">
337
+ <slot></slot>${hasDefaultSlot ? '' : this.cardTitle}
338
+ </span>
283
339
  <div
284
340
  aria-hidden=${hasDescription ? 'false' : 'true'}
285
341
  class="radio__description"
@@ -1,5 +1,6 @@
1
1
  @use "../../wc";
2
2
  @use "../../form-control";
3
+ @use "../../selection-card";
3
4
 
4
5
  :host {
5
6
  display: block;
@@ -7,4 +7,60 @@ describe('<zn-radio>', () => {
7
7
 
8
8
  expect(el).to.exist;
9
9
  });
10
+
11
+ it('renders card content and a built-in image', async () => {
12
+ const el = await fixture(html`
13
+ <zn-radio
14
+ contained
15
+ card-title="Basic"
16
+ description="For smaller businesses."
17
+ src="plan.svg"
18
+ image-alt="Basic plan">
19
+ </zn-radio>
20
+ `);
21
+
22
+ const image = el.shadowRoot!.querySelector('[part="image"]')!;
23
+ expect(image.getAttribute('src')).to.equal('plan.svg');
24
+ expect(image.getAttribute('alt')).to.equal('Basic plan');
25
+ expect(el.shadowRoot!.querySelector('[part="label"]')!.textContent).to.include('Basic');
26
+ });
27
+
28
+ it('renders card-title when formatting whitespace is present in the default slot', async () => {
29
+ const el = await fixture(html`
30
+ <zn-radio contained card-title="Complete">
31
+ <zn-icon slot="image" src="potted_plant"></zn-icon>
32
+ </zn-radio>
33
+ `);
34
+
35
+ expect(el.shadowRoot!.querySelector('[part="card-title"]')!.textContent).to.include('Complete');
36
+ });
37
+
38
+ it('positions card images and controls', async () => {
39
+ const el = await fixture(html`
40
+ <zn-radio contained image-position="top" control-position="bottom-right">
41
+ Complete
42
+ <zn-icon slot="image" src="potted_plant"></zn-icon>
43
+ </zn-radio>
44
+ `);
45
+ const base = el.shadowRoot!.querySelector('[part="base"]')!;
46
+
47
+ expect(base).to.have.class('selection-card--image-top');
48
+ expect(base).to.have.class('selection-card--control-bottom-right');
49
+ expect(el.shadowRoot!.querySelector('slot[name="image"]')).to.exist;
50
+ });
51
+
52
+ it('leaves a plain contained radio without card styles', async () => {
53
+ const el = await fixture(html`<zn-radio contained description="Supporting text">Basic</zn-radio>`);
54
+
55
+ expect(el.shadowRoot!.querySelector('[part="base"]')).not.to.have.class('selection-card');
56
+ expect(el.shadowRoot!.querySelector('[part="image-container"]')).to.be.null;
57
+ });
58
+
59
+ it('can hide the visual control without removing the native input', async () => {
60
+ const el = await fixture(html`<zn-radio contained control-position="none">Basic</zn-radio>`);
61
+
62
+ expect(el.shadowRoot!.querySelector('input[type="radio"]')).to.exist;
63
+ expect(el.shadowRoot!.querySelector('[part~="control"]')).to.exist;
64
+ expect(el.shadowRoot!.querySelector('[part="base"]')).to.have.class('selection-card--control-none');
65
+ });
10
66
  });
@@ -30,7 +30,7 @@ import styles from './radio-group.scss';
30
30
  *
31
31
  * @csspart base - The component's base wrapper.
32
32
  *
33
- * @cssproperty --example - An example CSS custom property.
33
+ * @cssproperty --zn-radio-group-column-width - Minimum column width used by the horizontal contained grid, or the card basis when `wrap` is set.
34
34
  */
35
35
  export default class ZnRadioGroup extends ZincElement implements ZincFormControl {
36
36
  static styles: CSSResultGroup = unsafeCSS(styles);
@@ -67,12 +67,21 @@ export default class ZnRadioGroup extends ZincElement implements ZincFormControl
67
67
  /** The radio group's size. This size will be applied to all child radios */
68
68
  @property({reflect: true}) size: 'small' | 'medium' | 'large' = 'medium';
69
69
 
70
- /** The checkbox group's orientation. Changes the group's layout from the default (vertical) to horizontal. */
70
+ /** The radio group's orientation. Changes the group's layout from the default (vertical) to horizontal. */
71
71
  @property({type: Boolean, reflect: true}) horizontal = false;
72
72
 
73
- /** The checkbox group's style. Changes the group's style from the default (plain) style to the 'contained' style. This style will be applied to all child checkboxes. */
73
+ /** The radio group's style. Changes the group's style from the default (plain) style to the 'contained' style. This style will be applied to all child radios. */
74
74
  @property({type: Boolean, reflect: true}) contained = false;
75
75
 
76
+ /** Squares off the corners of every contained radio in the group, which are rounded by default. */
77
+ @property({type: Boolean, reflect: true}) square = false;
78
+
79
+ /**
80
+ * Lays horizontal contained radios out as a wrapping row instead of an equal-width grid, so each one sizes from
81
+ * `--zn-radio-group-column-width` (set it to `auto` to size from content) and wraps onto a new line when needed.
82
+ */
83
+ @property({type: Boolean, reflect: true}) wrap = false;
84
+
76
85
  /**
77
86
  * By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
78
87
  * to place the form control outside a form and associate it with the form that has this `id`. The form must be in
@@ -190,6 +199,10 @@ export default class ZnRadioGroup extends ZincElement implements ZincFormControl
190
199
  radio.size = this.size;
191
200
  radio.horizontal = this.horizontal;
192
201
  radio.contained = this.contained;
202
+
203
+ if (this.square) {
204
+ radio.square = true;
205
+ }
193
206
  })
194
207
  );
195
208
 
@@ -8,18 +8,21 @@
8
8
 
9
9
  :host([horizontal]) .form-control-input {
10
10
  display: flex;
11
+ flex-wrap: wrap;
11
12
  column-gap: var(--zn-spacing-2x-large);
13
+ row-gap: var(--zn-spacing-small);
12
14
  }
13
15
 
14
- :host([contained]) .form-control-input {
16
+ /* The label supplies the gap above the options, so a group without one starts flush. */
17
+ :host([contained]) .form-control--has-label .form-control-input {
15
18
  margin-top: var(--zn-spacing-medium);
16
19
  }
17
20
 
18
21
  :host([horizontal][contained]) .form-control-input {
19
- margin-top: var(--zn-spacing-medium);
20
22
  display: grid;
21
- grid-template-columns: repeat(auto-fit, minmax(152px, 1fr));
22
- gap: var(--zn-spacing-x-small);
23
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--zn-radio-group-column-width, 152px)), 1fr));
24
+ align-items: stretch;
25
+ gap: var(--zn-spacing-small);
23
26
  }
24
27
 
25
28
  ::slotted(zn-radio) {
@@ -36,6 +39,22 @@
36
39
  height: 100%;
37
40
  }
38
41
 
42
+ /* `wrap` swaps the equal-width grid for a flex row: cards start from their basis and wrap onto new lines. */
43
+ :host([horizontal][contained][wrap]) .form-control-input {
44
+ display: flex;
45
+ flex-wrap: wrap;
46
+ align-items: stretch;
47
+ gap: var(--zn-base-gap);
48
+ }
49
+
50
+ :host([wrap]) ::slotted(zn-radio[horizontal][contained]) {
51
+ flex: 1 1 var(--zn-radio-group-column-width, 152px);
52
+ min-width: 0;
53
+
54
+ /* A percentage height would resolve against every line, not the card's own. */
55
+ height: auto;
56
+ }
57
+
39
58
  .form-control {
40
59
  position: relative;
41
60
  border: none;
@@ -7,4 +7,19 @@ describe('<zn-radio-group>', () => {
7
7
 
8
8
  expect(el).to.exist;
9
9
  });
10
+
11
+ it('propagates contained and square to its radios', async () => {
12
+ const el = await fixture<HTMLElement>(html`
13
+ <zn-radio-group contained square>
14
+ <zn-radio value="one">One</zn-radio>
15
+ <zn-radio value="two">Two</zn-radio>
16
+ </zn-radio-group>
17
+ `);
18
+ await new Promise(resolve => requestAnimationFrame(resolve));
19
+
20
+ el.querySelectorAll('zn-radio').forEach(child => {
21
+ expect(child).to.have.attribute('contained');
22
+ expect(child).to.have.attribute('square');
23
+ });
24
+ });
10
25
  });