@warp-ds/elements 2.8.0-next.2 → 2.8.0-next.3
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/dist/custom-elements.json +4097 -3178
- package/dist/index.d.ts +1053 -832
- package/dist/packages/combobox/combobox.react.stories.d.ts +1 -1
- package/dist/packages/radio/base-element.d.ts +46 -0
- package/dist/packages/radio/base-element.js +100 -0
- package/dist/packages/radio/custom-error-validator.d.ts +6 -0
- package/dist/packages/radio/custom-error-validator.js +22 -0
- package/dist/packages/radio/form-associated-element.d.ts +103 -0
- package/dist/packages/radio/form-associated-element.js +282 -0
- package/dist/packages/radio/host-styles.d.ts +1 -0
- package/dist/packages/radio/host-styles.js +12 -0
- package/dist/packages/radio/invalid.d.ts +8 -0
- package/dist/packages/radio/invalid.js +5 -0
- package/dist/packages/radio/radio-styles.d.ts +1 -0
- package/dist/packages/radio/radio-styles.js +148 -0
- package/dist/packages/radio/radio.a11y.test.d.ts +2 -0
- package/dist/packages/radio/radio.a11y.test.js +81 -0
- package/dist/packages/radio/radio.d.ts +53 -0
- package/dist/packages/radio/radio.js +2602 -0
- package/dist/packages/radio/radio.js.map +7 -0
- package/dist/packages/radio/radio.react.stories.d.ts +9 -0
- package/dist/packages/radio/radio.react.stories.js +10 -0
- package/dist/packages/radio/radio.stories.d.ts +32 -0
- package/dist/packages/radio/radio.stories.js +275 -0
- package/dist/packages/radio/radio.test.d.ts +1 -0
- package/dist/packages/radio/radio.test.js +185 -0
- package/dist/packages/radio/react.d.ts +2 -0
- package/dist/packages/radio/react.js +11 -0
- package/dist/packages/radio/required-validator.d.ts +11 -0
- package/dist/packages/radio/required-validator.js +34 -0
- package/dist/packages/radio/slot.d.ts +20 -0
- package/dist/packages/radio/slot.js +71 -0
- package/dist/packages/radio/watch.d.ts +26 -0
- package/dist/packages/radio/watch.js +39 -0
- package/dist/packages/radio-group/locales/da/messages.d.mts +1 -0
- package/dist/packages/radio-group/locales/da/messages.mjs +1 -0
- package/dist/packages/radio-group/locales/en/messages.d.mts +1 -0
- package/dist/packages/radio-group/locales/en/messages.mjs +1 -0
- package/dist/packages/radio-group/locales/fi/messages.d.mts +1 -0
- package/dist/packages/radio-group/locales/fi/messages.mjs +1 -0
- package/dist/packages/radio-group/locales/nb/messages.d.mts +1 -0
- package/dist/packages/radio-group/locales/nb/messages.mjs +1 -0
- package/dist/packages/radio-group/locales/sv/messages.d.mts +1 -0
- package/dist/packages/radio-group/locales/sv/messages.mjs +1 -0
- package/dist/packages/radio-group/radio-group-styles.d.ts +1 -0
- package/dist/packages/radio-group/radio-group-styles.js +61 -0
- package/dist/packages/radio-group/radio-group.a11y.test.d.ts +2 -0
- package/dist/packages/radio-group/radio-group.a11y.test.js +118 -0
- package/dist/packages/radio-group/radio-group.d.ts +88 -0
- package/dist/packages/radio-group/radio-group.js +2704 -0
- package/dist/packages/radio-group/radio-group.js.map +7 -0
- package/dist/packages/radio-group/radio-group.test.d.ts +2 -0
- package/dist/packages/radio-group/radio-group.test.js +392 -0
- package/dist/packages/radio-group/react.d.ts +7 -0
- package/dist/packages/radio-group/react.js +17 -0
- package/dist/packages/select/select.react.stories.d.ts +1 -1
- package/dist/packages/textarea/textarea.react.stories.d.ts +1 -1
- package/dist/packages/textfield/textfield.react.stories.d.ts +1 -1
- package/dist/web-types.json +579 -398
- package/package.json +1 -1
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { html } from 'lit';
|
|
2
|
+
import { expect, test } from 'vitest';
|
|
3
|
+
import { render } from 'vitest-browser-lit';
|
|
4
|
+
import './radio.js';
|
|
5
|
+
test('checks on click and remains checked on subsequent clicks', async () => {
|
|
6
|
+
render(html `<w-radio value="alpha">Alpha</w-radio>`);
|
|
7
|
+
const radio = document.querySelector('w-radio');
|
|
8
|
+
await radio.updateComplete;
|
|
9
|
+
expect(radio.checked).toBe(false);
|
|
10
|
+
radio.click();
|
|
11
|
+
await radio.updateComplete;
|
|
12
|
+
expect(radio.checked).toBe(true);
|
|
13
|
+
radio.click();
|
|
14
|
+
await radio.updateComplete;
|
|
15
|
+
expect(radio.checked).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
test('does not check when disabled', async () => {
|
|
18
|
+
render(html `<w-radio value="alpha" disabled>Alpha</w-radio>`);
|
|
19
|
+
const radio = document.querySelector('w-radio');
|
|
20
|
+
await radio.updateComplete;
|
|
21
|
+
expect(radio.checked).toBe(false);
|
|
22
|
+
expect(radio.getAttribute('aria-disabled')).toBe('true');
|
|
23
|
+
expect(radio.tabIndex).toBe(-1);
|
|
24
|
+
radio.click();
|
|
25
|
+
await radio.updateComplete;
|
|
26
|
+
expect(radio.checked).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
test('updates aria-checked and tabIndex when checked', async () => {
|
|
29
|
+
render(html `<w-radio value="alpha">Alpha</w-radio>`);
|
|
30
|
+
const radio = document.querySelector('w-radio');
|
|
31
|
+
await radio.updateComplete;
|
|
32
|
+
expect(radio.getAttribute('aria-checked')).toBe('false');
|
|
33
|
+
radio.checked = true;
|
|
34
|
+
await radio.updateComplete;
|
|
35
|
+
expect(radio.getAttribute('aria-checked')).toBe('true');
|
|
36
|
+
expect(radio.tabIndex).toBe(0);
|
|
37
|
+
});
|
|
38
|
+
test('checked state uses selected border color', async () => {
|
|
39
|
+
render(html `<w-radio value="alpha">Alpha</w-radio>`);
|
|
40
|
+
const radio = document.querySelector('w-radio');
|
|
41
|
+
await radio.updateComplete;
|
|
42
|
+
const control = radio.shadowRoot?.querySelector('.control');
|
|
43
|
+
if (!control) {
|
|
44
|
+
throw new Error('Expected radio control element to exist');
|
|
45
|
+
}
|
|
46
|
+
radio.click();
|
|
47
|
+
await radio.updateComplete;
|
|
48
|
+
const swatch = document.createElement('div');
|
|
49
|
+
swatch.style.borderColor = 'var(--w-s-color-border-selected)';
|
|
50
|
+
document.body.append(swatch);
|
|
51
|
+
const selectedColor = getComputedStyle(swatch).borderColor;
|
|
52
|
+
swatch.remove();
|
|
53
|
+
expect(getComputedStyle(control).borderColor).toBe(selectedColor);
|
|
54
|
+
});
|
|
55
|
+
test('disabled control uses disabled background and border colors', async () => {
|
|
56
|
+
render(html `<w-radio value="alpha" disabled>Alpha</w-radio>`);
|
|
57
|
+
const radio = document.querySelector('w-radio');
|
|
58
|
+
await radio.updateComplete;
|
|
59
|
+
const control = radio.shadowRoot?.querySelector('.control');
|
|
60
|
+
if (!control) {
|
|
61
|
+
throw new Error('Expected radio control element to exist');
|
|
62
|
+
}
|
|
63
|
+
const bgSwatch = document.createElement('div');
|
|
64
|
+
bgSwatch.style.backgroundColor = 'var(--w-s-color-background-disabled-subtle)';
|
|
65
|
+
document.body.append(bgSwatch);
|
|
66
|
+
const disabledBg = getComputedStyle(bgSwatch).backgroundColor;
|
|
67
|
+
bgSwatch.remove();
|
|
68
|
+
const borderSwatch = document.createElement('div');
|
|
69
|
+
borderSwatch.style.borderColor = 'var(--w-s-color-border-disabled)';
|
|
70
|
+
document.body.append(borderSwatch);
|
|
71
|
+
const disabledBorder = getComputedStyle(borderSwatch).borderColor;
|
|
72
|
+
borderSwatch.remove();
|
|
73
|
+
const controlStyle = getComputedStyle(control);
|
|
74
|
+
expect(controlStyle.backgroundColor).toBe(disabledBg);
|
|
75
|
+
expect(controlStyle.borderColor).toBe(disabledBorder);
|
|
76
|
+
});
|
|
77
|
+
test('reflects disabled state changes and updates tabIndex', async () => {
|
|
78
|
+
render(html `<w-radio value="alpha">Alpha</w-radio>`);
|
|
79
|
+
const radio = document.querySelector('w-radio');
|
|
80
|
+
await radio.updateComplete;
|
|
81
|
+
expect(radio.getAttribute('aria-disabled')).toBe('false');
|
|
82
|
+
expect(radio.tabIndex).toBe(0);
|
|
83
|
+
radio.checked = true;
|
|
84
|
+
await radio.updateComplete;
|
|
85
|
+
expect(radio.tabIndex).toBe(0);
|
|
86
|
+
radio.disabled = true;
|
|
87
|
+
await radio.updateComplete;
|
|
88
|
+
expect(radio.getAttribute('aria-disabled')).toBe('true');
|
|
89
|
+
expect(radio.tabIndex).toBe(-1);
|
|
90
|
+
radio.disabled = false;
|
|
91
|
+
await radio.updateComplete;
|
|
92
|
+
expect(radio.getAttribute('aria-disabled')).toBe('false');
|
|
93
|
+
expect(radio.tabIndex).toBe(0);
|
|
94
|
+
});
|
|
95
|
+
test('focuses the host element', async () => {
|
|
96
|
+
render(html `<w-radio value="alpha">Alpha</w-radio>`);
|
|
97
|
+
const radio = document.querySelector('w-radio');
|
|
98
|
+
await radio.updateComplete;
|
|
99
|
+
radio.focus();
|
|
100
|
+
await expect.poll(() => document.activeElement).toBe(radio);
|
|
101
|
+
});
|
|
102
|
+
test('associates selected value with form submission', async () => {
|
|
103
|
+
render(html `
|
|
104
|
+
<form>
|
|
105
|
+
<w-radio name="choice" value="alpha">Alpha</w-radio>
|
|
106
|
+
<w-radio name="choice" value="beta">Beta</w-radio>
|
|
107
|
+
</form>
|
|
108
|
+
`);
|
|
109
|
+
const form = document.querySelector('form');
|
|
110
|
+
const radios = Array.from(document.querySelectorAll('w-radio'));
|
|
111
|
+
await radios[0].updateComplete;
|
|
112
|
+
let data = new FormData(form);
|
|
113
|
+
expect(data.get('choice')).toBeNull();
|
|
114
|
+
radios[0].click();
|
|
115
|
+
await radios[0].updateComplete;
|
|
116
|
+
data = new FormData(form);
|
|
117
|
+
expect(data.get('choice')).toBe('alpha');
|
|
118
|
+
radios[1].click();
|
|
119
|
+
await radios[1].updateComplete;
|
|
120
|
+
expect(radios[0].checked).toBe(false);
|
|
121
|
+
expect(radios[1].checked).toBe(true);
|
|
122
|
+
data = new FormData(form);
|
|
123
|
+
expect(data.get('choice')).toBe('beta');
|
|
124
|
+
});
|
|
125
|
+
test('standalone radios with same name are mutually exclusive', async () => {
|
|
126
|
+
render(html `
|
|
127
|
+
<w-radio name="group" value="one">One</w-radio>
|
|
128
|
+
<w-radio name="group" value="two">Two</w-radio>
|
|
129
|
+
`);
|
|
130
|
+
const radios = Array.from(document.querySelectorAll('w-radio'));
|
|
131
|
+
await radios[0].updateComplete;
|
|
132
|
+
radios[0].click();
|
|
133
|
+
await radios[0].updateComplete;
|
|
134
|
+
expect(radios[0].checked).toBe(true);
|
|
135
|
+
expect(radios[1].checked).toBe(false);
|
|
136
|
+
radios[1].click();
|
|
137
|
+
await radios[1].updateComplete;
|
|
138
|
+
expect(radios[0].checked).toBe(false);
|
|
139
|
+
expect(radios[1].checked).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
test('standalone radios with same name use roving tab order', async () => {
|
|
142
|
+
render(html `
|
|
143
|
+
<w-radio name="group" value="one">One</w-radio>
|
|
144
|
+
<w-radio name="group" value="two">Two</w-radio>
|
|
145
|
+
<w-radio name="group" value="three">Three</w-radio>
|
|
146
|
+
`);
|
|
147
|
+
const radios = Array.from(document.querySelectorAll('w-radio'));
|
|
148
|
+
await Promise.all(radios.map((radio) => radio.updateComplete));
|
|
149
|
+
expect(radios[0].tabIndex).toBe(0);
|
|
150
|
+
expect(radios[1].tabIndex).toBe(-1);
|
|
151
|
+
expect(radios[2].tabIndex).toBe(-1);
|
|
152
|
+
radios[2].click();
|
|
153
|
+
await Promise.all(radios.map((radio) => radio.updateComplete));
|
|
154
|
+
expect(radios[0].tabIndex).toBe(-1);
|
|
155
|
+
expect(radios[1].tabIndex).toBe(-1);
|
|
156
|
+
expect(radios[2].tabIndex).toBe(0);
|
|
157
|
+
});
|
|
158
|
+
test('arrow keys move selection between standalone radios with same name', async () => {
|
|
159
|
+
render(html `
|
|
160
|
+
<w-radio name="group" value="one">One</w-radio>
|
|
161
|
+
<w-radio name="group" value="two">Two</w-radio>
|
|
162
|
+
<w-radio name="group" value="three">Three</w-radio>
|
|
163
|
+
`);
|
|
164
|
+
const radios = Array.from(document.querySelectorAll('w-radio'));
|
|
165
|
+
await Promise.all(radios.map((radio) => radio.updateComplete));
|
|
166
|
+
radios[0].focus();
|
|
167
|
+
radios[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
168
|
+
await Promise.all(radios.map((radio) => radio.updateComplete));
|
|
169
|
+
expect(radios[0].checked).toBe(false);
|
|
170
|
+
expect(radios[1].checked).toBe(true);
|
|
171
|
+
expect(radios[1].tabIndex).toBe(0);
|
|
172
|
+
await expect.poll(() => document.activeElement).toBe(radios[1]);
|
|
173
|
+
});
|
|
174
|
+
test('required radio reports validity based on checked state', async () => {
|
|
175
|
+
render(html `<w-radio name="choice" value="alpha" required>Alpha</w-radio>`);
|
|
176
|
+
const radio = document.querySelector('w-radio');
|
|
177
|
+
await radio.updateComplete;
|
|
178
|
+
await expect.poll(() => radio.reportValidity()).toBe(false);
|
|
179
|
+
expect(radio.validationMessage).not.toBe('');
|
|
180
|
+
expect(radio.invalid).toBe(true);
|
|
181
|
+
radio.click();
|
|
182
|
+
await radio.updateComplete;
|
|
183
|
+
await expect.poll(() => radio.reportValidity()).toBe(true);
|
|
184
|
+
expect(radio.invalid).toBe(false);
|
|
185
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createComponent } from '@lit/react';
|
|
2
|
+
import { LitElement } from 'lit';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
// decouple from CDN by providing a dummy class
|
|
5
|
+
class Component extends LitElement {
|
|
6
|
+
}
|
|
7
|
+
export const Radio = createComponent({
|
|
8
|
+
tagName: 'w-radio',
|
|
9
|
+
elementClass: Component,
|
|
10
|
+
react: React,
|
|
11
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Validator } from './form-associated-element';
|
|
2
|
+
export interface RequiredValidatorOptions {
|
|
3
|
+
/** This is a cheap way for us to get translation strings for the user without having proper translations. */
|
|
4
|
+
validationElement?: HTMLSelectElement | HTMLInputElement;
|
|
5
|
+
/**
|
|
6
|
+
* The property to check if its not null-ish. For most elements this will be "value".
|
|
7
|
+
* For "checkbox" for example it will be "checked"
|
|
8
|
+
*/
|
|
9
|
+
validationProperty?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare const RequiredValidator: (options?: RequiredValidatorOptions) => Validator;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const RequiredValidator = (options = {}) => {
|
|
2
|
+
let { validationElement, validationProperty } = options;
|
|
3
|
+
if (!validationElement) {
|
|
4
|
+
validationElement = Object.assign(document.createElement('input'), { required: true });
|
|
5
|
+
}
|
|
6
|
+
if (!validationProperty) {
|
|
7
|
+
validationProperty = 'value';
|
|
8
|
+
}
|
|
9
|
+
const obj = {
|
|
10
|
+
observedAttributes: ['required'],
|
|
11
|
+
message: validationElement.validationMessage, // @TODO: Add a translation.
|
|
12
|
+
checkValidity(element) {
|
|
13
|
+
const validity = {
|
|
14
|
+
message: '',
|
|
15
|
+
isValid: true,
|
|
16
|
+
invalidKeys: [],
|
|
17
|
+
};
|
|
18
|
+
const isRequired = element.required ?? element.hasAttribute('required');
|
|
19
|
+
// Always true if the element isn't required.
|
|
20
|
+
if (!isRequired) {
|
|
21
|
+
return validity;
|
|
22
|
+
}
|
|
23
|
+
const value = element[validationProperty];
|
|
24
|
+
const isEmpty = !value;
|
|
25
|
+
if (isEmpty) {
|
|
26
|
+
validity.message = typeof obj.message === 'function' ? obj.message(element) : obj.message || '';
|
|
27
|
+
validity.isValid = false;
|
|
28
|
+
validity.invalidKeys.push('valueMissing');
|
|
29
|
+
}
|
|
30
|
+
return validity;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
return obj;
|
|
34
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
|
2
|
+
/** A reactive controller that determines when slots exist. */
|
|
3
|
+
export declare class HasSlotController implements ReactiveController {
|
|
4
|
+
host: ReactiveControllerHost & Element;
|
|
5
|
+
slotNames: string[];
|
|
6
|
+
constructor(host: ReactiveControllerHost & Element, ...slotNames: string[]);
|
|
7
|
+
private hasDefaultSlot;
|
|
8
|
+
private hasNamedSlot;
|
|
9
|
+
test(slotName: string): boolean;
|
|
10
|
+
hostConnected(): void;
|
|
11
|
+
hostDisconnected(): void;
|
|
12
|
+
private handleSlotChange;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Given a list of nodes, this function iterates over all of them and returns the concatenated
|
|
16
|
+
* HTML as a string. This is useful for getting the HTML that corresponds to a slot’s assigned nodes (since we can't use slot.innerHTML as an alternative).
|
|
17
|
+
* @param nodes - The list of nodes to iterate over.
|
|
18
|
+
* @param callback - A function that can be used to customize the HTML output for specific types of nodes. If the function returns undefined, the default HTML output will be used.
|
|
19
|
+
*/
|
|
20
|
+
export declare function getInnerHTML(nodes: Iterable<Node>, callback?: (node: Node) => string | undefined): string;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** A reactive controller that determines when slots exist. */
|
|
2
|
+
export class HasSlotController {
|
|
3
|
+
constructor(host, ...slotNames) {
|
|
4
|
+
this.slotNames = [];
|
|
5
|
+
this.handleSlotChange = (event) => {
|
|
6
|
+
const slot = event.target;
|
|
7
|
+
if ((this.slotNames.includes('[default]') && !slot.name) || (slot.name && this.slotNames.includes(slot.name))) {
|
|
8
|
+
this.host.requestUpdate();
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
(this.host = host).addController(this);
|
|
12
|
+
this.slotNames = slotNames;
|
|
13
|
+
}
|
|
14
|
+
hasDefaultSlot() {
|
|
15
|
+
return [...this.host.childNodes].some((node) => {
|
|
16
|
+
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim() !== '') {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
20
|
+
const el = node;
|
|
21
|
+
const tagName = el.tagName.toLowerCase();
|
|
22
|
+
// Ignore visually hidden elements since they aren't rendered
|
|
23
|
+
if (tagName === 'w-visually-hidden') {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
// If it doesn't have a slot attribute, it's part of the default slot
|
|
27
|
+
if (!el.hasAttribute('slot')) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
hasNamedSlot(name) {
|
|
35
|
+
return this.host.querySelector(`:scope > [slot="${name}"]`) !== null;
|
|
36
|
+
}
|
|
37
|
+
test(slotName) {
|
|
38
|
+
return slotName === '[default]' ? this.hasDefaultSlot() : this.hasNamedSlot(slotName);
|
|
39
|
+
}
|
|
40
|
+
hostConnected() {
|
|
41
|
+
this.host.shadowRoot?.addEventListener('slotchange', this.handleSlotChange);
|
|
42
|
+
}
|
|
43
|
+
hostDisconnected() {
|
|
44
|
+
this.host.shadowRoot?.removeEventListener('slotchange', this.handleSlotChange);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Given a list of nodes, this function iterates over all of them and returns the concatenated
|
|
49
|
+
* HTML as a string. This is useful for getting the HTML that corresponds to a slot’s assigned nodes (since we can't use slot.innerHTML as an alternative).
|
|
50
|
+
* @param nodes - The list of nodes to iterate over.
|
|
51
|
+
* @param callback - A function that can be used to customize the HTML output for specific types of nodes. If the function returns undefined, the default HTML output will be used.
|
|
52
|
+
*/
|
|
53
|
+
export function getInnerHTML(nodes, callback) {
|
|
54
|
+
let html = '';
|
|
55
|
+
for (const node of nodes) {
|
|
56
|
+
if (callback) {
|
|
57
|
+
const customHTML = callback(node);
|
|
58
|
+
if (customHTML !== undefined) {
|
|
59
|
+
html += customHTML;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
64
|
+
html += node.outerHTML;
|
|
65
|
+
}
|
|
66
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
67
|
+
html += node.textContent;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return html;
|
|
71
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { LitElement } from 'lit';
|
|
2
|
+
type UpdateHandler = (prev?: unknown, next?: unknown) => void;
|
|
3
|
+
type NonUndefined<A> = A extends undefined ? never : A;
|
|
4
|
+
type UpdateHandlerFunctionKeys<T extends object> = {
|
|
5
|
+
[K in keyof T]-?: NonUndefined<T[K]> extends UpdateHandler ? K : never;
|
|
6
|
+
}[keyof T];
|
|
7
|
+
interface WatchOptions {
|
|
8
|
+
/**
|
|
9
|
+
* If true, will only start watching after the initial update/render
|
|
10
|
+
*/
|
|
11
|
+
waitUntilFirstUpdate?: boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Runs when observed properties change, e.g. @property or @state, but before the component updates. To wait for an
|
|
15
|
+
* update to complete after a change occurs, use `await this.updateComplete` in the handler. To start watching after the
|
|
16
|
+
* initial update/render, use `{ waitUntilFirstUpdate: true }` or `this.hasUpdated` in the handler.
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
*
|
|
20
|
+
* @watch('propName')
|
|
21
|
+
* handlePropChange(oldValue, newValue) {
|
|
22
|
+
* ...
|
|
23
|
+
* }
|
|
24
|
+
*/
|
|
25
|
+
export declare function watch(propertyName: string | string[], options?: WatchOptions): <ElemClass extends LitElement>(proto: ElemClass, decoratedFnName: UpdateHandlerFunctionKeys<ElemClass>) => void;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs when observed properties change, e.g. @property or @state, but before the component updates. To wait for an
|
|
3
|
+
* update to complete after a change occurs, use `await this.updateComplete` in the handler. To start watching after the
|
|
4
|
+
* initial update/render, use `{ waitUntilFirstUpdate: true }` or `this.hasUpdated` in the handler.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
*
|
|
8
|
+
* @watch('propName')
|
|
9
|
+
* handlePropChange(oldValue, newValue) {
|
|
10
|
+
* ...
|
|
11
|
+
* }
|
|
12
|
+
*/
|
|
13
|
+
export function watch(propertyName, options) {
|
|
14
|
+
const resolvedOptions = {
|
|
15
|
+
waitUntilFirstUpdate: false,
|
|
16
|
+
...options,
|
|
17
|
+
};
|
|
18
|
+
return (proto, decoratedFnName) => {
|
|
19
|
+
// @ts-expect-error - update is a protected property
|
|
20
|
+
const { update } = proto;
|
|
21
|
+
const watchedProperties = Array.isArray(propertyName) ? propertyName : [propertyName];
|
|
22
|
+
// @ts-expect-error - update is a protected property
|
|
23
|
+
proto.update = function (changedProps) {
|
|
24
|
+
watchedProperties.forEach((property) => {
|
|
25
|
+
const key = property;
|
|
26
|
+
if (changedProps.has(key)) {
|
|
27
|
+
const oldValue = changedProps.get(key);
|
|
28
|
+
const newValue = this[key];
|
|
29
|
+
if (oldValue !== newValue) {
|
|
30
|
+
if (!resolvedOptions.waitUntilFirstUpdate || this.hasUpdated) {
|
|
31
|
+
this[decoratedFnName](oldValue, newValue);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
update.call(this, changedProps);
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const messages: any;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/*eslint-disable*/ export const messages = JSON.parse("{\"radio-group.validation.required\":[\"Vælg en mulighed.\"],\"radio-group.label.optional\":[\"Valgfri\"]}");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const messages: any;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/*eslint-disable*/ export const messages = JSON.parse("{\"radio-group.validation.required\":[\"Please select an option.\"],\"radio-group.label.optional\":[\"Optional\"]}");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const messages: any;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/*eslint-disable*/ export const messages = JSON.parse("{\"radio-group.validation.required\":[\"Valitse vaihtoehto.\"],\"radio-group.label.optional\":[\"Valinnainen\"]}");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const messages: any;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/*eslint-disable*/ export const messages = JSON.parse("{\"radio-group.validation.required\":[\"Velg et alternativ.\"],\"radio-group.label.optional\":[\"Valgfri\"]}");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const messages: any;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/*eslint-disable*/ export const messages = JSON.parse("{\"radio-group.validation.required\":[\"Välj ett alternativ.\"],\"radio-group.label.optional\":[\"Valfritt\"]}");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const styles: import("lit").CSSResult;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { css } from 'lit';
|
|
2
|
+
export const styles = css `
|
|
3
|
+
:host {
|
|
4
|
+
display: block;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.form-control {
|
|
8
|
+
position: relative;
|
|
9
|
+
border: none;
|
|
10
|
+
padding: 0;
|
|
11
|
+
margin: 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.label {
|
|
15
|
+
font-size: var(--w-font-size-s);
|
|
16
|
+
line-height: var(--w-line-height-s);
|
|
17
|
+
font-weight: 700;
|
|
18
|
+
-webkit-font-smoothing: antialiased;
|
|
19
|
+
-moz-osx-font-smoothing: grayscale;
|
|
20
|
+
font-smoothing: grayscale;
|
|
21
|
+
cursor: pointer;
|
|
22
|
+
padding-bottom: 16px;
|
|
23
|
+
color: var(--w-s-color-text);
|
|
24
|
+
display: block;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.optional {
|
|
28
|
+
font-weight: 400;
|
|
29
|
+
color: var(--w-s-color-text-subtle);
|
|
30
|
+
margin-inline-start: 0.5rem;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.radio-group-required .label::after {
|
|
34
|
+
content: var(--wa-form-control-required-content);
|
|
35
|
+
margin-inline-start: var(--wa-form-control-required-content-offset);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
[part~='form-control-input'] {
|
|
39
|
+
display: flex;
|
|
40
|
+
flex-direction: column;
|
|
41
|
+
flex-wrap: wrap;
|
|
42
|
+
gap: 16px;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Help text */
|
|
46
|
+
[part~='help-text'] {
|
|
47
|
+
margin-block-start: 16px;
|
|
48
|
+
font-size: var(--w-font-size-xs);
|
|
49
|
+
line-height: var(--w-line-height-xs);
|
|
50
|
+
color: var(--w-s-color-text-subtle);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
:host([disabled]) [part~='help-text'] {
|
|
54
|
+
color: var(--w-s-color-text-disabled);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
[part~='help-text'].error {
|
|
58
|
+
color: var(--w-s-color-text-negative);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
`;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { i18n } from '@lingui/core';
|
|
2
|
+
import { userEvent } from '@vitest/browser/context';
|
|
3
|
+
import { html } from 'lit';
|
|
4
|
+
import { describe, expect, test } from 'vitest';
|
|
5
|
+
import { render } from 'vitest-browser-lit';
|
|
6
|
+
import './radio-group.js';
|
|
7
|
+
import '../radio/radio.js';
|
|
8
|
+
// Initialize i18n with English locale for tests
|
|
9
|
+
i18n.load('en', {
|
|
10
|
+
'radio-group.label.optional': ['(optional)'],
|
|
11
|
+
'radio-group.validation.required': ['Please select an option.'],
|
|
12
|
+
});
|
|
13
|
+
i18n.activate('en');
|
|
14
|
+
describe('w-radio-group accessibility (WCAG 2.2)', () => {
|
|
15
|
+
describe('axe-core automated checks', () => {
|
|
16
|
+
test('default state has no violations', async () => {
|
|
17
|
+
const page = render(html `
|
|
18
|
+
<w-radio-group label="Preferences">
|
|
19
|
+
<w-radio value="one">One</w-radio>
|
|
20
|
+
<w-radio value="two">Two</w-radio>
|
|
21
|
+
</w-radio-group>
|
|
22
|
+
`);
|
|
23
|
+
await expect(page).toHaveNoAxeViolations();
|
|
24
|
+
});
|
|
25
|
+
test('with help text has no violations', async () => {
|
|
26
|
+
const page = render(html `
|
|
27
|
+
<w-radio-group label="Preferences" help-text="Select one">
|
|
28
|
+
<w-radio value="one">One</w-radio>
|
|
29
|
+
<w-radio value="two">Two</w-radio>
|
|
30
|
+
</w-radio-group>
|
|
31
|
+
`);
|
|
32
|
+
await expect(page).toHaveNoAxeViolations();
|
|
33
|
+
});
|
|
34
|
+
test('required invalid state has no violations', async () => {
|
|
35
|
+
const page = render(html `
|
|
36
|
+
<w-radio-group label="Preferences" required>
|
|
37
|
+
<w-radio value="one">One</w-radio>
|
|
38
|
+
<w-radio value="two">Two</w-radio>
|
|
39
|
+
</w-radio-group>
|
|
40
|
+
`);
|
|
41
|
+
const group = document.querySelector('w-radio-group');
|
|
42
|
+
group.reportValidity?.();
|
|
43
|
+
await expect(page).toHaveNoAxeViolations();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe('WCAG 1.3.1 - Info and Relationships', () => {
|
|
47
|
+
test('group has accessible name from label', async () => {
|
|
48
|
+
const page = render(html `
|
|
49
|
+
<w-radio-group label="Preferences">
|
|
50
|
+
<w-radio value="one">One</w-radio>
|
|
51
|
+
<w-radio value="two">Two</w-radio>
|
|
52
|
+
</w-radio-group>
|
|
53
|
+
`);
|
|
54
|
+
await expect.element(page.getByRole('radiogroup', { name: 'Preferences' })).toBeVisible();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe('WCAG 3.3.2 - Labels or Instructions', () => {
|
|
58
|
+
test('help text is programmatically associated', async () => {
|
|
59
|
+
const page = render(html `
|
|
60
|
+
<w-radio-group label="Preferences" help-text="Select one">
|
|
61
|
+
<w-radio value="one">One</w-radio>
|
|
62
|
+
<w-radio value="two">Two</w-radio>
|
|
63
|
+
</w-radio-group>
|
|
64
|
+
`);
|
|
65
|
+
await expect.element(page.getByRole('radiogroup', { name: 'Preferences' })).toHaveAccessibleDescription('Select one');
|
|
66
|
+
});
|
|
67
|
+
test('slotted label and help text are associated', async () => {
|
|
68
|
+
const page = render(html `
|
|
69
|
+
<w-radio-group>
|
|
70
|
+
<span slot="label">Slotted label</span>
|
|
71
|
+
<span slot="help-text">Slotted help text</span>
|
|
72
|
+
<w-radio value="one">One</w-radio>
|
|
73
|
+
</w-radio-group>
|
|
74
|
+
`);
|
|
75
|
+
await expect.element(page.getByRole('radiogroup', { name: 'Slotted label' })).toHaveAccessibleDescription('Slotted help text');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
describe('WCAG 2.1.1 - Keyboard', () => {
|
|
79
|
+
test('arrow keys move selection between radios', async () => {
|
|
80
|
+
render(html `
|
|
81
|
+
<w-radio-group label="Preferences">
|
|
82
|
+
<w-radio value="one">One</w-radio>
|
|
83
|
+
<w-radio value="two">Two</w-radio>
|
|
84
|
+
<w-radio value="three">Three</w-radio>
|
|
85
|
+
</w-radio-group>
|
|
86
|
+
`);
|
|
87
|
+
const radios = Array.from(document.querySelectorAll('w-radio'));
|
|
88
|
+
await radios[0].updateComplete;
|
|
89
|
+
radios[0].focus();
|
|
90
|
+
await expect.poll(() => document.activeElement).toBe(radios[0]);
|
|
91
|
+
await userEvent.keyboard('[ArrowDown]');
|
|
92
|
+
await expect.poll(() => radios[1].checked).toBe(true);
|
|
93
|
+
await expect.poll(() => document.activeElement).toBe(radios[1]);
|
|
94
|
+
await userEvent.keyboard('[ArrowDown]');
|
|
95
|
+
await expect.poll(() => radios[2].checked).toBe(true);
|
|
96
|
+
await expect.poll(() => document.activeElement).toBe(radios[2]);
|
|
97
|
+
});
|
|
98
|
+
test('arrow keys wrap and skip disabled radios', async () => {
|
|
99
|
+
render(html `
|
|
100
|
+
<w-radio-group label="Preferences">
|
|
101
|
+
<w-radio value="one">One</w-radio>
|
|
102
|
+
<w-radio value="two" disabled>Two</w-radio>
|
|
103
|
+
<w-radio value="three">Three</w-radio>
|
|
104
|
+
</w-radio-group>
|
|
105
|
+
`);
|
|
106
|
+
const radios = Array.from(document.querySelectorAll('w-radio'));
|
|
107
|
+
await radios[0].updateComplete;
|
|
108
|
+
radios[0].focus();
|
|
109
|
+
await expect.poll(() => document.activeElement).toBe(radios[0]);
|
|
110
|
+
await userEvent.keyboard('[ArrowDown]');
|
|
111
|
+
await expect.poll(() => radios[2].checked).toBe(true);
|
|
112
|
+
await expect.poll(() => document.activeElement).toBe(radios[2]);
|
|
113
|
+
await userEvent.keyboard('[ArrowDown]');
|
|
114
|
+
await expect.poll(() => radios[0].checked).toBe(true);
|
|
115
|
+
await expect.poll(() => document.activeElement).toBe(radios[0]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|