@digital-realty/ix-radio 1.0.1

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/src/IxRadio.ts ADDED
@@ -0,0 +1,153 @@
1
+ import { html, LitElement, isServer } from 'lit';
2
+ import {ifDefined} from 'lit/directives/if-defined.js';
3
+ import { property } from 'lit/decorators.js';
4
+ import '@material/web/radio/radio.js';
5
+ import {isActivationClick} from '@material/web/internal/controller/events.js';
6
+ import { IxRadioStyles } from './ix-radio-styles.js';
7
+ import {SingleSelectionController} from './single-selection-controller.js';
8
+ import {polyfillElementInternalsAria, setupHostAria} from '@material/web/internal/aria/aria.js';
9
+
10
+ const CHECKED = Symbol('checked');
11
+
12
+ export class IxRadio extends LitElement {
13
+ static get styles() {
14
+ return [IxRadioStyles];
15
+ }
16
+
17
+ /** @nocollapse */
18
+ static readonly formAssociated = true;
19
+
20
+ /**
21
+ * Whether or not the radio is selected.
22
+ */
23
+ @property({type: Boolean})
24
+ get checked() {
25
+ return this[CHECKED];
26
+ }
27
+ set checked(checked: boolean) {
28
+ const wasChecked = this.checked;
29
+ if (wasChecked === checked) {
30
+ return;
31
+ }
32
+
33
+ this[CHECKED] = checked;
34
+ const state = String(checked);
35
+ this.internals.setFormValue(this.checked ? this.value : null, state);
36
+ this.requestUpdate('checked', wasChecked);
37
+ this.selectionController.handleCheckedChange();
38
+ }
39
+
40
+ [CHECKED] = false;
41
+
42
+ @property({type: Boolean, reflect: true}) disabled = false;
43
+
44
+ @property() label: string = '';
45
+
46
+ /**
47
+ * The element value to use in form submission when checked.
48
+ */
49
+ @property() value = 'on';
50
+
51
+ @property() ariaLabel: string = '';
52
+
53
+ @property() target: 'wrapper' | '' = '';
54
+
55
+ @property() htmlId: string | undefined;
56
+
57
+ /**
58
+ * The HTML name to use in form submission.
59
+ */
60
+ get name() {
61
+ return this.getAttribute('name') ?? '';
62
+ }
63
+ set name(name: string) {
64
+ this.setAttribute('name', name);
65
+ }
66
+
67
+ /**
68
+ * The associated form element with which this element's value will submit.
69
+ */
70
+ get form() {
71
+ return this.internals.form;
72
+ }
73
+
74
+ /**
75
+ * The labels this element is associated with.
76
+ */
77
+ get labels() {
78
+ return this.internals.labels;
79
+ }
80
+
81
+ private readonly selectionController = new SingleSelectionController(this);
82
+
83
+ private readonly internals = polyfillElementInternalsAria(
84
+ this, (this as HTMLElement /* needed for closure */).attachInternals());
85
+
86
+ private async handleClick(event: Event) {
87
+ if (this.disabled) {
88
+ return;
89
+ }
90
+
91
+ // allow event to propagate to user code after a microtask.
92
+ await 0;
93
+ if (event.defaultPrevented) {
94
+ return;
95
+ }
96
+
97
+ if (isActivationClick(event)) {
98
+ this.focus();
99
+ }
100
+
101
+ // Per spec, clicking on a radio input always selects it.
102
+ this.checked = true;
103
+ this.dispatchEvent(new Event('change', {bubbles: true}));
104
+ this.dispatchEvent(
105
+ new InputEvent('input', {bubbles: true, composed: true}));
106
+ }
107
+
108
+ private async handleKeydown(event: KeyboardEvent) {
109
+ // allow event to propagate to user code after a microtask.
110
+ await 0;
111
+ if (event.key !== ' ' || event.defaultPrevented) {
112
+ return;
113
+ }
114
+
115
+ this.click();
116
+ }
117
+
118
+ constructor() {
119
+ super();
120
+ this.addController(this.selectionController);
121
+ if (!isServer) {
122
+ this.internals.role = 'radio';
123
+ this.addEventListener('click', this.handleClick.bind(this));
124
+ this.addEventListener('keydown', this.handleKeydown.bind(this));
125
+ }
126
+ }
127
+
128
+ protected override updated() {
129
+ this.internals.ariaChecked = String(this.checked);
130
+ }
131
+
132
+ get renderRadio() {
133
+ return html`
134
+ <md-radio
135
+ aria-label=${this.ariaLabel || this.label }
136
+ name=${this.name}
137
+ value=${this.value}
138
+ ?checked=${this.checked}
139
+ ?disabled=${this.disabled}
140
+ id=${ifDefined(this.htmlId)}
141
+ touch-target=${this.target}
142
+ ></md-radio>
143
+ `;
144
+ }
145
+
146
+ render() {
147
+ if (this.label)
148
+ return html`<label>${this.renderRadio}
149
+ <span> ${this.label}</span> </label>`;
150
+
151
+ return this.renderRadio;
152
+ }
153
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { IxRadio } from './IxRadio.js';
@@ -0,0 +1,3 @@
1
+ import { css } from 'lit';
2
+
3
+ export const IxRadioStyles = css``;
@@ -0,0 +1,3 @@
1
+ import { IxRadio } from './IxRadio.js';
2
+
3
+ window.customElements.define('ix-radio', IxRadio);
@@ -0,0 +1,15 @@
1
+ import React from 'react';
2
+ import { createComponent } from '@lit-labs/react';
3
+ import { IxRadio as IxRadioLit } from '../IxRadio.js';
4
+
5
+ window.customElements.define('ix-button', IxRadioLit);
6
+
7
+ export const IxButton = createComponent({
8
+ tagName: 'ix-radio',
9
+ elementClass: IxRadioLit,
10
+ react: React,
11
+ events: {
12
+ onclick: 'onClick',
13
+ onkeydown: 'keydown'
14
+ },
15
+ });
@@ -0,0 +1,231 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2022 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {ReactiveController} from 'lit';
8
+
9
+ /**
10
+ * An element that supports single-selection with `SingleSelectionController`.
11
+ */
12
+ export interface SingleSelectionElement extends HTMLElement {
13
+ /**
14
+ * Whether or not the element is selected.
15
+ */
16
+ checked: boolean;
17
+ }
18
+
19
+ /**
20
+ * A `ReactiveController` that provides root node-scoped single selection for
21
+ * elements, similar to native `<input type="radio">` selection.
22
+ *
23
+ * To use, elements should add the controller and call
24
+ * `selectionController.handleCheckedChange()` in a getter/setter. This must
25
+ * be synchronous to match native behavior.
26
+ *
27
+ * @example
28
+ * const CHECKED = Symbol('checked');
29
+ *
30
+ * class MyToggle extends LitElement {
31
+ * get checked() { return this[CHECKED]; }
32
+ * set checked(checked: boolean) {
33
+ * const oldValue = this.checked;
34
+ * if (oldValue === checked) {
35
+ * return;
36
+ * }
37
+ *
38
+ * this[CHECKED] = checked;
39
+ * this.selectionController.handleCheckedChange();
40
+ * this.requestUpdate('checked', oldValue);
41
+ * }
42
+ *
43
+ * [CHECKED] = false;
44
+ *
45
+ * private selectionController = new SingleSelectionController(this);
46
+ *
47
+ * constructor() {
48
+ * super();
49
+ * this.addController(this.selectionController);
50
+ * }
51
+ * }
52
+ */
53
+ export class SingleSelectionController implements ReactiveController {
54
+ private focused = false;
55
+
56
+ private root: ParentNode|null = null;
57
+
58
+ constructor(private readonly host: SingleSelectionElement) {}
59
+
60
+ hostConnected() {
61
+ this.root = this.host.getRootNode() as ParentNode;
62
+ this.host.addEventListener('keydown', this.handleKeyDown);
63
+ this.host.addEventListener('focusin', this.handleFocusIn);
64
+ this.host.addEventListener('focusout', this.handleFocusOut);
65
+ if (this.host.checked) {
66
+ // Uncheck other siblings when attached if already checked. This mimics
67
+ // native <input type="radio"> behavior.
68
+ this.uncheckSiblings();
69
+ }
70
+
71
+ // Update for the newly added host.
72
+ this.updateTabIndices();
73
+ }
74
+
75
+ hostDisconnected() {
76
+ this.host.removeEventListener('keydown', this.handleKeyDown);
77
+ this.host.removeEventListener('focusin', this.handleFocusIn);
78
+ this.host.removeEventListener('focusout', this.handleFocusOut);
79
+ // Update for siblings that are still connected.
80
+ this.updateTabIndices();
81
+ this.root = null;
82
+ }
83
+
84
+ /**
85
+ * Should be called whenever the host's `checked` property changes
86
+ * synchronously.
87
+ */
88
+ handleCheckedChange() {
89
+ if (!this.host.checked) {
90
+ return;
91
+ }
92
+
93
+ this.uncheckSiblings();
94
+ this.updateTabIndices();
95
+ }
96
+
97
+ private readonly handleFocusIn = () => {
98
+ this.focused = true;
99
+ this.updateTabIndices();
100
+ };
101
+
102
+ private readonly handleFocusOut = () => {
103
+ this.focused = false;
104
+ this.updateTabIndices();
105
+ };
106
+
107
+ private uncheckSiblings() {
108
+ for (const sibling of this.getNamedSiblings()) {
109
+ if (sibling !== this.host) {
110
+ sibling.checked = false;
111
+ }
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Updates the `tabindex` of the host and its siblings.
117
+ */
118
+ private updateTabIndices() {
119
+ // There are three tabindex states for a group of elements:
120
+ // 1. If any are checked, that element is focusable.
121
+ const siblings = this.getNamedSiblings();
122
+ const checkedSibling = siblings.find(sibling => sibling.checked);
123
+ // 2. If an element is focused, the others are no longer focusable.
124
+ if (checkedSibling || this.focused) {
125
+ const focusable = checkedSibling || this.host;
126
+ focusable.tabIndex = 0;
127
+
128
+ for (const sibling of siblings) {
129
+ if (sibling !== focusable) {
130
+ sibling.tabIndex = -1;
131
+ }
132
+ }
133
+ return;
134
+ }
135
+
136
+ // 3. If none are checked or focused, all are focusable.
137
+ for (const sibling of siblings) {
138
+ sibling.tabIndex = 0;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Retrieves all siblings in the host element's root with the same `name`
144
+ * attribute.
145
+ */
146
+ private getNamedSiblings() {
147
+ const name = this.host.getAttribute('name');
148
+ if (!name || !this.root) {
149
+ return [];
150
+ }
151
+
152
+ return Array.from(
153
+ this.root.querySelectorAll<SingleSelectionElement>(`[name="${name}"]`));
154
+ }
155
+
156
+ /**
157
+ * Handles arrow key events from the host. Using the arrow keys will
158
+ * select and check the next or previous sibling with the host's
159
+ * `name` attribute.
160
+ */
161
+ private readonly handleKeyDown = (event: KeyboardEvent) => {
162
+ const isDown = event.key === 'ArrowDown';
163
+ const isUp = event.key === 'ArrowUp';
164
+ const isLeft = event.key === 'ArrowLeft';
165
+ const isRight = event.key === 'ArrowRight';
166
+ // Ignore non-arrow keys
167
+ if (!isLeft && !isRight && !isDown && !isUp) {
168
+ return;
169
+ }
170
+
171
+ // Don't try to select another sibling if there aren't any.
172
+ const siblings = this.getNamedSiblings();
173
+ if (!siblings.length) {
174
+ return;
175
+ }
176
+
177
+ // Prevent default interactions on the element for arrow keys,
178
+ // since this controller will introduce new behavior.
179
+ event.preventDefault();
180
+
181
+ // Check if moving forwards or backwards
182
+ const isRtl = getComputedStyle(this.host).direction === 'rtl';
183
+ const forwards = isRtl ? isLeft || isDown : isRight || isDown;
184
+
185
+ const hostIndex = siblings.indexOf(this.host);
186
+ let nextIndex = forwards ? hostIndex + 1 : hostIndex - 1;
187
+ // Search for the next sibling that is not disabled to select.
188
+ // If we return to the host index, there is nothing to select.
189
+ while (nextIndex !== hostIndex) {
190
+ if (nextIndex >= siblings.length) {
191
+ // Return to start if moving past the last item.
192
+ nextIndex = 0;
193
+ } else if (nextIndex < 0) {
194
+ // Go to end if moving before the first item.
195
+ nextIndex = siblings.length - 1;
196
+ }
197
+
198
+ // Check if the next sibling is disabled. If so,
199
+ // move the index and continue searching.
200
+ const nextSibling = siblings[nextIndex];
201
+ if (nextSibling.hasAttribute('disabled')) {
202
+ if (forwards) {
203
+ nextIndex++;
204
+ } else {
205
+ nextIndex--;
206
+ }
207
+
208
+ continue;
209
+ }
210
+
211
+ // Uncheck and remove focusability from other siblings.
212
+ for (const sibling of siblings) {
213
+ if (sibling !== nextSibling) {
214
+ sibling.checked = false;
215
+ sibling.tabIndex = -1;
216
+ sibling.blur();
217
+ }
218
+ }
219
+
220
+ // The next sibling should be checked, focused and dispatch a change event
221
+ nextSibling.checked = true;
222
+ nextSibling.tabIndex = 0;
223
+ nextSibling.focus();
224
+ // Fire a change event since the change is triggered by a user action.
225
+ // This matches native <input type="radio"> behavior.
226
+ nextSibling.dispatchEvent(new Event('change', {bubbles: true}));
227
+
228
+ break;
229
+ }
230
+ };
231
+ }
@@ -0,0 +1,50 @@
1
+ import { html } from 'lit';
2
+ import { fixture, expect } from '@open-wc/testing';
3
+ import { IxRadio } from '../src/IxRadio.js';
4
+ import '../src/ix-radio.js';
5
+ import { triggerBlurFor } from '@open-wc/testing';
6
+
7
+ describe('IxRadio', () => {
8
+ it('should render with label and id', async () => {
9
+ const props = {
10
+ label: 'Red',
11
+ ariaLabel: 'Red',
12
+ value: 'Red',
13
+ target: '',
14
+ };
15
+ const el = await fixture<IxRadio>(html`
16
+ <ix-radio
17
+ .label=${props.label}
18
+ .ariaLabel=${props.ariaLabel}
19
+ .value=${props.value}
20
+ htmlId="test-id"
21
+ ></ix-radio>
22
+ `);
23
+
24
+ const radio = el.shadowRoot?.querySelector('md-radio');
25
+ const label = el.shadowRoot?.querySelector('label');
26
+ expect(radio).to.be.not.null;
27
+ expect(radio?.id).to.equal("test-id");
28
+ expect(label).to.be.not.null;
29
+ expect(label?.innerText.trim()).to.equal('Red');
30
+ });
31
+
32
+ it('should not render the label or id if none are specified', async () => {
33
+ const props = {
34
+ ariaLabel: 'Red',
35
+ value: 'Red',
36
+ };
37
+ const el = await fixture<IxRadio>(html`
38
+ <ix-radio
39
+ .ariaLabel=${props.ariaLabel}
40
+ .value=${props.value}
41
+ ></ix-radio>
42
+ `);
43
+
44
+ const radio = el.shadowRoot?.querySelector('md-radio');
45
+ const label = el.shadowRoot?.querySelector('label');
46
+ expect(radio?.id).to.equal("");
47
+ expect(radio).to.be.not.null;
48
+ expect(label).to.be.null;
49
+ });
50
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2018",
4
+ "module": "esnext",
5
+ "moduleResolution": "node",
6
+ "noEmitOnError": true,
7
+ "lib": ["es2017", "dom"],
8
+ "strict": true,
9
+ "esModuleInterop": false,
10
+ "allowSyntheticDefaultImports": true,
11
+ "experimentalDecorators": true,
12
+ "importHelpers": true,
13
+ "outDir": "dist",
14
+ "sourceMap": true,
15
+ "inlineSources": true,
16
+ "rootDir": "./",
17
+ "declaration": true,
18
+ "incremental": true
19
+ },
20
+ "include": ["**/*.ts"]
21
+ }
@@ -0,0 +1,27 @@
1
+ // import { hmrPlugin, presets } from '@open-wc/dev-server-hmr';
2
+
3
+ /** Use Hot Module replacement by adding --hmr to the start command */
4
+ const hmr = process.argv.includes('--hmr');
5
+
6
+ export default /** @type {import('@web/dev-server').DevServerConfig} */ ({
7
+ open: '/demo/',
8
+ /** Use regular watch mode if HMR is not enabled. */
9
+ watch: !hmr,
10
+ /** Resolve bare module imports */
11
+ nodeResolve: {
12
+ exportConditions: ['browser', 'development'],
13
+ },
14
+
15
+ /** Compile JS for older browsers. Requires @web/dev-server-esbuild plugin */
16
+ // esbuildTarget: 'auto'
17
+
18
+ /** Set appIndex to enable SPA routing */
19
+ // appIndex: 'demo/index.html',
20
+
21
+ plugins: [
22
+ /** Use Hot Module Replacement by uncommenting. Requires @open-wc/dev-server-hmr plugin */
23
+ // hmr && hmrPlugin({ exclude: ['**/*/node_modules/**/*'], presets: [presets.litElement] }),
24
+ ],
25
+
26
+ // See documentation for all available options
27
+ });
@@ -0,0 +1,41 @@
1
+ // import { playwrightLauncher } from '@web/test-runner-playwright';
2
+
3
+ const filteredLogs = ['Running in dev mode', 'lit-html is in dev mode'];
4
+
5
+ export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({
6
+ /** Test files to run */
7
+ files: 'dist/test/**/*.test.js',
8
+
9
+ /** Resolve bare module imports */
10
+ nodeResolve: {
11
+ exportConditions: ['browser', 'development'],
12
+ },
13
+
14
+ /** Filter out lit dev mode logs */
15
+ filterBrowserLogs(log) {
16
+ for (const arg of log.args) {
17
+ if (typeof arg === 'string' && filteredLogs.some(l => arg.includes(l))) {
18
+ return false;
19
+ }
20
+ }
21
+ return true;
22
+ },
23
+
24
+ /** Compile JS for older browsers. Requires @web/dev-server-esbuild plugin */
25
+ // esbuildTarget: 'auto',
26
+
27
+ /** Amount of browsers to run concurrently */
28
+ // concurrentBrowsers: 2,
29
+
30
+ /** Amount of test files per browser to test concurrently */
31
+ // concurrency: 1,
32
+
33
+ /** Browsers to run tests on */
34
+ // browsers: [
35
+ // playwrightLauncher({ product: 'chromium' }),
36
+ // playwrightLauncher({ product: 'firefox' }),
37
+ // playwrightLauncher({ product: 'webkit' }),
38
+ // ],
39
+
40
+ // See documentation for all available options
41
+ });