diff --git a/.agents/skills/authoring-documentation/SKILL.md b/.agents/skills/authoring-documentation/SKILL.md
index af610acc8f..047741ab05 100644
--- a/.agents/skills/authoring-documentation/SKILL.md
+++ b/.agents/skills/authoring-documentation/SKILL.md
@@ -19,11 +19,37 @@ You MUST review @projects/site/src/docs/internal/guidelines/documentation.md bef
## Key Principles
1. **Frontmatter Required**: All documentation files must include proper frontmatter with title and layout
-2. **Eleventy Shortcodes**: Use built-in shortcodes for common patterns (code examples, callouts, etc.)
+2. **Source-Owned API Documentation**: Write component guidance in source JSDoc and render it with Eleventy shortcodes so the site, CLI, and MCP share the same documentation.
3. **Clear Structure**: Use proper heading hierarchy and table of contents for long documents
4. **Code Examples**: Include working code examples with proper syntax highlighting
5. **Cross-References**: Link to related documentation and guidelines using proper paths
+## Single Source of Truth for Component Documentation
+
+Component JSDoc feeds the generated Custom Elements Manifest (CEM). The API catalog used by the CLI and Model Context Protocol (MCP) tools consumes that metadata. The documentation site renders the same descriptions through shortcodes. Example JSDoc feeds the separate example catalog used by the site, CLI, and MCP.
+
+Guidance written only in a component's Markdown page does not automatically enter the CEM or those catalogs. A page can look complete while tools still lack the information needed to use the component correctly.
+
+### Where Information Belongs
+
+- Put component purpose and shared behavior in the class description. Document properties, attributes, slots, methods, and events on their source declarations or supported JSDoc tags. Include relevant defaults, constraints, accessibility behavior, units, and application responsibilities.
+- Put an example's purpose, usage context, and benefits in its `@summary`. Use `@description` for additional explanation, mappings, or source references. These annotations travel with the example metadata.
+- Keep component Markdown pages focused on frontmatter, headings, and `install`, `api`, and `example` shortcodes. Select the source description or property that owns the information instead of repeating its prose in Markdown.
+- Keep conceptual guides and tutorials in Markdown when their content is not a component or example API contract. This convention does not require moving general documentation into code.
+
+### Author and Verify at the Source
+
+When adding or correcting component guidance, update the owning JSDoc first, then add or adjust the shortcode that presents it. Do not compensate for missing source annotations with a page-only explanation.
+
+For example, render an annotated property and example with:
+
+```liquid
+{% api 'nve-joystick', 'property', 'i18n' %}
+{% example '@nvidia-elements/core/joystick/joystick.examples.json' 'AccessibleFeedback' %}
+```
+
+For metadata changes, use the affected project's documented build or analyzer tasks and regenerate downstream metadata as needed. Inspect the relevant generated CEM description or member, and the API or example catalog consumed by the tools. Verify the site renders the content through its shortcodes. A successful site build alone does not prove that CLI and MCP consumers receive the guidance.
+
## Prose Linting (Vale)
### How Vale Runs
diff --git a/projects/core/.visual/joystick.dark.png b/projects/core/.visual/joystick.dark.png
new file mode 100644
index 0000000000..1f8651af17
--- /dev/null
+++ b/projects/core/.visual/joystick.dark.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:49caf9850ccd092463dc6d03fe7aaa15bd5c20c51efe987c71c1ef066ca43436
+size 187449
diff --git a/projects/core/.visual/joystick.png b/projects/core/.visual/joystick.png
new file mode 100644
index 0000000000..d1077c7803
--- /dev/null
+++ b/projects/core/.visual/joystick.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:273a130b3f77741fb6280a8556ac907ba828a1003575546e34a6deeaaccb2321
+size 139278
diff --git a/projects/core/package.json b/projects/core/package.json
index 3f7e23e1bc..191ed31667 100644
--- a/projects/core/package.json
+++ b/projects/core/package.json
@@ -543,6 +543,18 @@
"types": "./dist/input/define.d.ts",
"default": "./dist/input/define.js"
},
+ "./joystick": {
+ "types": "./dist/joystick/index.d.ts",
+ "default": "./dist/joystick/index.js"
+ },
+ "./joystick/index.js": {
+ "types": "./dist/joystick/index.d.ts",
+ "default": "./dist/joystick/index.js"
+ },
+ "./joystick/define.js": {
+ "types": "./dist/joystick/define.d.ts",
+ "default": "./dist/joystick/define.js"
+ },
"./logo": {
"types": "./dist/logo/index.d.ts",
"default": "./dist/logo/index.js"
diff --git a/projects/core/src/bundle.ts b/projects/core/src/bundle.ts
index 7b8973b157..2114c332cf 100644
--- a/projects/core/src/bundle.ts
+++ b/projects/core/src/bundle.ts
@@ -40,6 +40,7 @@ import '@nvidia-elements/core/icon/define.js';
import '@nvidia-elements/core/icon-button/define.js';
import '@nvidia-elements/core/iframe/define.js';
import '@nvidia-elements/core/input/define.js';
+import '@nvidia-elements/core/joystick/define.js';
import '@nvidia-elements/core/logo/define.js';
import '@nvidia-elements/core/menu/define.js';
import '@nvidia-elements/core/month/define.js';
@@ -114,6 +115,7 @@ export * from '@nvidia-elements/core/icon';
export * from '@nvidia-elements/core/icon-button';
export * from '@nvidia-elements/core/iframe';
export * from '@nvidia-elements/core/input';
+export * from '@nvidia-elements/core/joystick';
export * from '@nvidia-elements/core/logo';
export * from '@nvidia-elements/core/menu';
export * from '@nvidia-elements/core/month';
diff --git a/projects/core/src/index.test.lighthouse.ts b/projects/core/src/index.test.lighthouse.ts
index fc54d6eb9c..a03c5acb64 100644
--- a/projects/core/src/index.test.lighthouse.ts
+++ b/projects/core/src/index.test.lighthouse.ts
@@ -66,6 +66,7 @@ describe('lighthouse report', () => {
import '@nvidia-elements/core/icon-button/define.js';
import '@nvidia-elements/core/iframe/define.js';
import '@nvidia-elements/core/input/define.js';
+ import '@nvidia-elements/core/joystick/define.js';
import '@nvidia-elements/core/logo/define.js';
import '@nvidia-elements/core/menu/define.js';
import '@nvidia-elements/core/month/define.js';
diff --git a/projects/core/src/joystick/define.ts b/projects/core/src/joystick/define.ts
new file mode 100644
index 0000000000..3996fb3d51
--- /dev/null
+++ b/projects/core/src/joystick/define.ts
@@ -0,0 +1,13 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { define } from '@nvidia-elements/foundations/internal/define';
+import { Joystick } from '@nvidia-elements/core/joystick';
+
+define(Joystick);
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'nve-joystick': Joystick;
+ }
+}
diff --git a/projects/core/src/joystick/index.ts b/projects/core/src/joystick/index.ts
new file mode 100644
index 0000000000..ac631a8999
--- /dev/null
+++ b/projects/core/src/joystick/index.ts
@@ -0,0 +1,4 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+export * from './joystick.js';
diff --git a/projects/core/src/joystick/joystick.css b/projects/core/src/joystick/joystick.css
new file mode 100644
index 0000000000..4ba72200e1
--- /dev/null
+++ b/projects/core/src/joystick/joystick.css
@@ -0,0 +1,145 @@
+/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */
+/* SPDX-License-Identifier: Apache-2.0 */
+
+:host {
+ --_size-scale: 1;
+ --width: calc(8.125rem * var(--_size-scale));
+ --height: calc(8.125rem * var(--_size-scale));
+ --background: var(--nve-sys-interaction-background);
+ --opacity: 0.6;
+ --border: var(--nve-ref-border-width-sm) solid var(--nve-ref-border-color);
+ --thumb-width: calc(var(--nve-ref-size-600) * var(--_size-scale));
+ --thumb-height: calc(var(--nve-ref-size-600) * var(--_size-scale));
+ --thumb-background: var(--nve-sys-text-muted-color);
+ --thumb-border: var(--nve-ref-border-width-sm) solid var(--nve-sys-layer-canvas-background);
+ --_axis-size: calc(3rem * var(--_size-scale));
+ display: inline-block;
+ width: var(--width);
+ height: var(--height);
+ user-select: none;
+}
+
+[internal-host] {
+ position: relative;
+ box-sizing: border-box;
+ width: 100%;
+ height: 100%;
+ border: var(--border);
+ border-radius: 50%;
+ cursor: grab;
+}
+
+.background {
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ border-radius: inherit;
+ background: var(--background);
+ pointer-events: none;
+}
+
+:host([opacity]) .background {
+ opacity: var(--opacity);
+}
+
+[internal-host]::before,
+[internal-host]::after {
+ content: '';
+ position: absolute;
+ z-index: 1;
+}
+
+[internal-host]::before {
+ left: 50%;
+ top: 0;
+ bottom: 0;
+ border-left: var(--nve-ref-border-width-sm) solid var(--nve-ref-border-color);
+}
+
+[internal-host]::after {
+ top: 50%;
+ left: 0;
+ right: 0;
+ border-top: var(--nve-ref-border-width-sm) solid var(--nve-ref-border-color);
+}
+
+.travel {
+ position: absolute;
+ inset: calc(var(--thumb-height) / 2) calc(var(--thumb-width) / 2);
+ z-index: 2;
+}
+
+.thumb {
+ position: absolute;
+ box-sizing: border-box;
+ width: var(--thumb-width);
+ height: var(--thumb-height);
+ border: var(--thumb-border);
+ border-radius: 50%;
+ background: var(--thumb-background);
+ transform: translate(-50%, -50%);
+}
+
+:host([constraint='square']) [internal-host] {
+ border-radius: var(--nve-ref-border-radius-md);
+}
+
+:host([size='sm']) {
+ --_size-scale: 0.8;
+}
+
+:host([size='lg']) {
+ --_size-scale: 1.2;
+}
+
+:host([constraint='horizontal']) {
+ --height: var(--_axis-size);
+}
+
+:host([constraint='vertical']) {
+ --width: var(--_axis-size);
+}
+
+:host([constraint='horizontal']) [internal-host],
+:host([constraint='vertical']) [internal-host] {
+ border-radius: var(--nve-ref-border-radius-md);
+}
+
+:host(:focus-visible) {
+ outline: none;
+}
+
+:host(:focus-visible) [internal-host] {
+ outline: var(--nve-ref-border-width-md) solid var(--nve-sys-accent-secondary-background);
+ outline-offset: var(--nve-ref-space-xs);
+}
+
+:host(:state(active)) {
+ --thumb-background: var(--nve-sys-accent-secondary-background);
+}
+
+:host(:state(active)) [internal-host] {
+ cursor: grabbing;
+}
+
+:host([disabled]) {
+ --background: var(--nve-sys-interaction-disabled-background);
+ --thumb-background: var(--nve-sys-interaction-disabled-color);
+}
+
+:host([disabled]) [internal-host],
+:host([readonly]) [internal-host] {
+ cursor: default;
+}
+
+.announcement {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: 0;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border: 0;
+}
diff --git a/projects/core/src/joystick/joystick.examples.ts b/projects/core/src/joystick/joystick.examples.ts
new file mode 100644
index 0000000000..c16c683ebd
--- /dev/null
+++ b/projects/core/src/joystick/joystick.examples.ts
@@ -0,0 +1,236 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { html } from 'lit';
+import '@nvidia-elements/core/joystick/define.js';
+import '@nvidia-elements/core/button/define.js';
+import '@nvidia-elements/core/range/define.js';
+
+export default { title: 'Elements/Joystick', component: 'nve-joystick' };
+
+/**
+ * @summary Select a direction and intensity with a circular movement region. Release the pointer or arrow keys to return to center.
+ * @description Both axes use normalized values from -1 to 1. Applications can derive length with Math.hypot(x, y) and angle with Math.atan2(-y, x). This angle uses radians from the rightward direction with counterclockwise positive rotation. Use undefined at neutral. Keep these derived quantities outside the writable value. The angle does not describe the robot's heading.
+ */
+export const Default = {
+ render: () => html` `
+};
+
+/**
+ * @summary Choose small, medium, or large controls to fit the available space. Each size scales the movement surface and thumb together.
+ */
+export const Size = {
+ render: () => html`
+
+
+
+
+
+ `
+};
+
+/**
+ * @summary Overlay a joystick with a translucent background on a checkered scene. Keep the border and thumb at full opacity for contrast.
+ * @tags theme
+ */
+export const Opacity = {
+ render: () => html`
+
+
+
+ `
+};
+
+/**
+ * @summary Compare circular, square, and single-axis movement regions. Choose a constraint that matches the available commands.
+ * @description Circle bounds normalized magnitude to one. Square allows independent full-scale axes and a magnitude of up to Math.SQRT2. Use circle for planar direction and intensity, or square for independent limits such as forward velocity and turning rate. Equally scaled planar velocity axes can produce about 41% greater diagonal speed with square input.
+ */
+export const Constraints = {
+ render: () => html`
+
+
+
+
+
+
+ `
+};
+
+/**
+ * @summary Keep the last position after release for persistent target selection. Press Escape during an interaction to return to center.
+ */
+export const Hold = {
+ render: () => html` `
+};
+
+/**
+ * @summary Display recorded input without generating control events. Use read-only state for inspection and disabled state for unavailable controls.
+ * @description Set readOnly to true before assigning recorded vectors through .value. Use undefined for an absent sample and a zero vector for recorded neutral input. The recording layer captures source, sample time, engagement transitions, and mapping configuration. Preserve its clock during replay and avoid applying input shaping to recorded samples again.
+ */
+export const States = {
+ render: () => html`
+
+
+
+
+ `
+};
+
+/**
+ * @summary Map travel and turn input to body-frame velocities in meters and radians per second. Engage either joystick to update its command axes.
+ * @description Input maps to ROS body axes: x-forward, y-left, z-up, with positive yaw counterclockwise. Travel uses square constraints for independent axis limits; use circle to bound normalized translation magnitude. The illustrative maxima are 1 m/s forward, 0.5 m/s lateral, and 1 rad/s yaw. Each control's :state(active) CSS custom state gates its displayed velocity. Interaction events refresh the readouts. The application chooses its limits and publishes the latest command at its own cadence, including while input remains stationary.
+ */
+export const TravelTurn = {
+ render: () => html`
+
+
+
+ Travel
+
+ Released: x 0.00, y 0.00
+
+
+ Turn
+
+ Released: x 0.00, y 0.00
+
+
+
vx 0.00 m/s, vy 0.00 m/s, wz 0.00 rad/s
+
+
+ `
+};
+
+/**
+ * @summary Adjust a radial dead zone and response curve before mapping input to commands. Compare the shaped vector with the unchanged joystick value.
+ * @description A dead zone of zero and response exponent of one leave circular input unchanged. The dead zone makes neutral easier to select; a larger exponent provides finer control near neutral. The remaining range scales back to full output at the boundary and preserves direction. Shaping produces a separate command vector without rewriting .value. Map it to physical units afterward. Apply filtering in one input layer to avoid processing hardware or recorded samples twice. [ROS joystick parameters](https://github.com/ros-drivers/joystick_drivers/blob/ros2/joy/README.md)
+ */
+export const InputShaping = {
+ render: () => html`
+
+
+
+ Dead zone
+
+
+
+ Response exponent
+
+
+ Dead zone 0.00, response 1.0
+ Raw: x 0.00, y 0.00
+ Shaped: x 0.00, y 0.00
+
+
+ `
+};
+
+/**
+ * @summary Submit both axes as separate named fields and restore the declared position with form reset. Use held values when selecting persistent form targets.
+ */
+export const Form = {
+ render: () => html`
+
+ `
+};
+
+/**
+ * @summary Override joystickPosition through i18n to localize spoken coordinates while keeping built-in position announcements.
+ */
+export const AccessibleFeedback = {
+ render: () => html`
+
+ `
+};
+
+/**
+ * @summary Supply application announcements with a labeled output. Disable built-in announcements to avoid duplicate feedback.
+ */
+export const ExternalFeedback = {
+ render: () => html`
+
+
+ Target: X 0, Y 0
+
+
+ `
+};
diff --git a/projects/core/src/joystick/joystick.test.axe.ts b/projects/core/src/joystick/joystick.test.axe.ts
new file mode 100644
index 0000000000..eb6faed396
--- /dev/null
+++ b/projects/core/src/joystick/joystick.test.axe.ts
@@ -0,0 +1,28 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { html } from 'lit';
+import { afterEach, describe, expect, it } from 'vitest';
+import { createFixture, elementIsStable, removeFixture } from '@internals/testing';
+import { runAxe } from '@internals/testing/axe';
+import '@nvidia-elements/core/joystick/define.js';
+
+describe('nve-joystick', () => {
+ let fixture: HTMLElement;
+ afterEach(() => removeFixture(fixture));
+
+ it('should provide named groups for each constraint and availability state', async () => {
+ fixture = await createFixture(html`
+
+
+
+
+
+
+
+ `);
+ await Promise.all([...fixture.querySelectorAll('nve-joystick')].map(element => elementIsStable(element)));
+ const result = await runAxe(['nve-joystick']);
+ expect(result.violations).toEqual([]);
+ });
+});
diff --git a/projects/core/src/joystick/joystick.test.lighthouse.ts b/projects/core/src/joystick/joystick.test.lighthouse.ts
new file mode 100644
index 0000000000..2961e4dd55
--- /dev/null
+++ b/projects/core/src/joystick/joystick.test.lighthouse.ts
@@ -0,0 +1,20 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, expect, it } from 'vitest';
+import { lighthouseRunner } from '@internals/vite';
+
+describe('joystick lighthouse report', () => {
+ it('should meet performance and accessibility budgets', async () => {
+ const report = await lighthouseRunner.getReport('nve-joystick', /* html */ `
+
+
+ `);
+ expect(report.scores.performance).toBe(100);
+ expect(report.scores.accessibility).toBe(100);
+ expect(report.scores.bestPractices).toBe(100);
+ expect(report.payload.javascript.kb).toBeLessThan(18);
+ });
+});
diff --git a/projects/core/src/joystick/joystick.test.ssr.ts b/projects/core/src/joystick/joystick.test.ssr.ts
new file mode 100644
index 0000000000..9f64556abc
--- /dev/null
+++ b/projects/core/src/joystick/joystick.test.ssr.ts
@@ -0,0 +1,21 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { html } from 'lit';
+import { describe, expect, it } from 'vitest';
+import { ssrRunner } from '@internals/vite';
+import '@nvidia-elements/core/joystick/define.js';
+
+describe('nve-joystick', () => {
+ it('should render a recorded vector without a browser interaction', async () => {
+ const result = await ssrRunner.render(
+ html` `
+ );
+ expect(result).toContain('shadowroot="open"');
+ expect(result).toContain('nve-joystick');
+ expect(result).toContain('70%');
+ expect(result).toContain('role="status"');
+ expect(result).toContain('aria-live="polite"');
+ expect(result).not.toContain('Returned to center.');
+ });
+});
diff --git a/projects/core/src/joystick/joystick.test.ts b/projects/core/src/joystick/joystick.test.ts
new file mode 100644
index 0000000000..1cabfc92d9
--- /dev/null
+++ b/projects/core/src/joystick/joystick.test.ts
@@ -0,0 +1,826 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { html } from 'lit';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { createFixture, elementIsStable, removeFixture } from '@internals/testing';
+import { Joystick, type JoystickInteractionEnd } from '@nvidia-elements/core/joystick';
+import '@nvidia-elements/core/joystick/define.js';
+import { I18nService } from '@nvidia-elements/foundations/i18n';
+
+describe(Joystick.metadata.tag, () => {
+ let fixture: HTMLElement;
+ let element: Joystick;
+ let form: HTMLFormElement;
+
+ beforeEach(async () => {
+ fixture = await createFixture(
+ html``
+ );
+ element = fixture.querySelector('nve-joystick');
+ form = fixture.querySelector('form');
+ await elementIsStable(element);
+ vi.spyOn(element, 'setPointerCapture').mockImplementation(() => {});
+ const travel = element.shadowRoot.querySelector('.travel');
+ vi.spyOn(travel, 'getBoundingClientRect').mockReturnValue(new DOMRect(10, 10, 100, 100));
+ });
+
+ afterEach(() => {
+ removeFixture(fixture);
+ vi.restoreAllMocks();
+ });
+
+ describe('accessible feedback', () => {
+ const status = () => element.shadowRoot.querySelector('[role="status"]');
+ const sendKey = (type: string, name = 'ArrowRight') =>
+ element.dispatchEvent(new KeyboardEvent(type, { key: name, bubbles: true }));
+ const advance = async (ms = 500) => {
+ await vi.advanceTimersByTimeAsync(ms);
+ await element.updateComplete;
+ };
+
+ afterEach(() => vi.useRealTimers());
+
+ it('should describe coordinates, precision, absent values, and availability', async () => {
+ element.constraint = 'square';
+ element.value = { x: 0.000001, y: -0 };
+ await elementIsStable(element);
+ expect(element._internals.ariaDescription).toContain('X 0.000001, Y 0.');
+ expect(element._internals.ariaDescription).toContain('Use arrow keys');
+ element.readOnly = true;
+ await elementIsStable(element);
+ expect(element._internals.ariaDescription).toBe('X 0.000001, Y 0.');
+ element.value = undefined;
+ await elementIsStable(element);
+ expect(element._internals.ariaDescription).toBe('No position.');
+ });
+
+ it.each(['horizontal', 'vertical'] as const)(
+ 'should describe both coordinates for the %s constraint',
+ async constraint => {
+ element.constraint = constraint;
+ element.release = 'hold';
+ await elementIsStable(element);
+ element.value = constraint === 'horizontal' ? { x: 0.2, y: 0 } : { x: 0, y: 0.2 };
+ await elementIsStable(element);
+ expect(element._internals.ariaDescription).toContain(
+ constraint === 'horizontal' ? 'X 0.2, Y 0.' : 'X 0, Y 0.2.'
+ );
+ expect(element._internals.ariaDescription).toContain('Release to keep');
+ }
+ );
+
+ it('should merge global translations with instance templates and update pending wording', async () => {
+ const original = I18nService.i18n.joystickPosition;
+ try {
+ I18nService.update({ joystickPosition: 'Global {x}, {y}.' });
+ await elementIsStable(element);
+ expect(element._internals.ariaDescription).toContain('Global 0, 0.');
+ element.i18n = { joystickPosition: 'Local {x}, {x}, {y}.' };
+ await elementIsStable(element);
+ expect(element._internals.ariaDescription).toContain('Local 0, 0, 0.');
+ element.focus();
+ vi.useFakeTimers();
+ sendKey('keydown');
+ sendKey('keyup');
+ element.i18n = { joystickReturnedToCenter: 'Released: {position}' };
+ await element.updateComplete;
+ await advance();
+ expect(status().textContent).toBe('Released: Global 0.1, 0.');
+ } finally {
+ I18nService.update({ joystickPosition: original });
+ }
+ });
+
+ it('should preserve a quick tap and its return without changing the name', async () => {
+ element.focus();
+ vi.useFakeTimers();
+ sendKey('keydown');
+ sendKey('keyup');
+ await advance(499);
+ expect(status().textContent).toBe('');
+ await advance(1);
+ expect(status().textContent).toBe('X 0.1, Y 0. Returned to center.');
+ expect(element._internals.ariaDescription).toContain('X 0, Y 0.');
+ expect(element._internals.ariaLabel).toBe('Movement');
+ sendKey('keydown');
+ sendKey('keyup');
+ await advance();
+ expect(status().textContent).toBe('X 0.1, Y 0. Returned to center.');
+ });
+
+ it('should coalesce movement, retain held values, and announce escape', async () => {
+ element.release = 'hold';
+ await elementIsStable(element);
+ element.focus();
+ vi.useFakeTimers();
+ sendKey('keydown');
+ await advance(250);
+ sendKey('keydown');
+ await advance(250);
+ expect(status().textContent).toBe('X 0.2, Y 0.');
+ sendKey('keyup');
+ await advance();
+ expect(element.value).toEqual({ x: 0.2, y: 0 });
+ sendKey('keydown');
+ sendKey('keydown', 'Escape');
+ await advance();
+ expect(status().textContent).toBe('X 0, Y 0.');
+ });
+
+ it('should keep unfocused and programmatic changes silent', async () => {
+ vi.useFakeTimers();
+ sendKey('keydown');
+ sendKey('keyup');
+ await advance();
+ expect(status().textContent).toBe('');
+ element.focus();
+ sendKey('keydown');
+ element.value = { x: 0.4, y: 0.2 };
+ await advance();
+ expect(status().textContent).toBe('');
+ expect(element._internals.ariaDescription).toContain('X 0.4, Y 0.2.');
+ });
+
+ it.each(['blur', 'disabled', 'readonly', 'off', 'reset', 'configuration', 'disconnect', 'hidden'])(
+ 'should discard pending feedback on %s',
+ async action => {
+ element.focus();
+ vi.useFakeTimers();
+ sendKey('keydown');
+ sendKey('keyup');
+ if (action === 'blur') element.blur();
+ if (action === 'disabled') element.disabled = true;
+ if (action === 'readonly') element.readOnly = true;
+ if (action === 'off') element.announce = 'off';
+ if (action === 'reset') element.reset();
+ if (action === 'configuration') element.keyboardStep = 0.2;
+ if (action === 'disconnect') element.remove();
+ if (action === 'hidden') {
+ vi.spyOn(document, 'hidden', 'get').mockReturnValue(true);
+ document.dispatchEvent(new Event('visibilitychange'));
+ }
+ await element.updateComplete;
+ await advance();
+ expect(status().textContent).toBe('');
+ }
+ );
+
+ it('should announce focused pointer input outside the hidden visual subtree', async () => {
+ element.release = 'hold';
+ await elementIsStable(element);
+ vi.useFakeTimers();
+ element.dispatchEvent(pointer('pointerdown', 85, 60));
+ await advance();
+ expect(status().textContent).toBe('X 0.5, Y 0.');
+ expect(status().closest('[aria-hidden="true"]')).toBeNull();
+ element.dispatchEvent(pointer('pointerup', 85, 60));
+ });
+
+ it('should localize numbers and tolerate invalid document languages', async () => {
+ const original = document.documentElement.lang;
+ try {
+ document.documentElement.lang = 'de';
+ element.value = { x: 0.25, y: -0.5 };
+ await elementIsStable(element);
+ expect(element._internals.ariaDescription).toContain('X 0,25, Y -0,5.');
+ document.documentElement.lang = 'invalid_locale';
+ element.value = { x: 0, y: 0 };
+ await elementIsStable(element);
+ expect(element._internals.ariaDescription).toContain('X 0, Y 0.');
+ } finally {
+ document.documentElement.lang = original;
+ }
+ });
+
+ it('should not replay muted feedback and should treat unknown announce values as polite', async () => {
+ element.focus();
+ vi.useFakeTimers();
+ sendKey('keydown');
+ sendKey('keyup');
+ element.announce = 'off';
+ await element.updateComplete;
+ element.setAttribute('announce', 'unknown');
+ await element.updateComplete;
+ await advance();
+ expect(status().textContent).toBe('');
+ sendKey('keydown');
+ sendKey('keyup');
+ await advance();
+ expect(status().textContent).toContain('Returned to center.');
+ expect(status().getAttribute('aria-live')).toBe('polite');
+ });
+
+ it.each([false, true])(
+ 'should suppress stale feedback after a synchronous application write (same coordinates: %s)',
+ async same => {
+ element.focus();
+ vi.useFakeTimers();
+ element.addEventListener(
+ 'input',
+ () => {
+ element.value = same ? element.value : { x: 0.4, y: 0.2 };
+ },
+ { once: true }
+ );
+ sendKey('keydown');
+ await advance();
+ expect(status().textContent).toBe('');
+ }
+ );
+
+ it('should retain the latest input when an input listener starts a nested adjustment', async () => {
+ element.focus();
+ vi.useFakeTimers();
+ element.addEventListener('input', () => sendKey('keydown'), { once: true });
+ sendKey('keydown');
+ await advance();
+ expect(status().textContent).toBe('X 0.2, Y 0.');
+ });
+
+ it('should preserve authored descriptions and support off without reflection', async () => {
+ element.setAttribute('aria-description', 'Application help');
+ element.setAttribute('aria-describedby', 'help');
+ element.announce = 'off';
+ await elementIsStable(element);
+ expect(element.getAttribute('aria-description')).toBe('Application help');
+ expect(element.getAttribute('aria-describedby')).toBe('help');
+ expect(element.hasAttribute('announce')).toBe(false);
+ expect(status().getAttribute('aria-live')).toBe('off');
+ expect(element._internals.ariaDescription).toContain('X 0, Y 0.');
+ });
+ });
+
+ it('should register and submit a neutral vector through its associated form', () => {
+ expect(customElements.get('nve-joystick')).toBe(Joystick);
+ expect(element.value).toEqual({ x: 0, y: 0 });
+ expect(element.form).toBe(form);
+ expect(Object.fromEntries(new FormData(form))).toEqual({ 'movement-x': '0', 'movement-y': '0' });
+ expect(element.composedLabel).toBe('Movement');
+ });
+
+ it('should manage the default focus order across enabled-state updates', async () => {
+ expect(element.tabIndex).toBe(0);
+ element.disabled = true;
+ await elementIsStable(element);
+ expect(element.tabIndex).toBe(-1);
+ element.disabled = false;
+ await elementIsStable(element);
+ expect(element.tabIndex).toBe(0);
+ });
+
+ it.each([-1, 0])('should preserve a declared tabindex of %s across updates and reconnection', async tabIndex => {
+ const other = document.createElement('nve-joystick');
+ other.setAttribute('tabindex', String(tabIndex));
+ fixture.append(other);
+ await elementIsStable(other);
+ expect(other.tabIndex).toBe(tabIndex);
+ other.value = { x: 0.2, y: 0.3 };
+ other.disabled = true;
+ await elementIsStable(other);
+ expect(other.tabIndex).toBe(tabIndex);
+ other.disabled = false;
+ await elementIsStable(other);
+ expect(other.tabIndex).toBe(tabIndex);
+ other.remove();
+ fixture.append(other);
+ await elementIsStable(other);
+ expect(other.tabIndex).toBe(tabIndex);
+ });
+
+ it.each(['property', 'attribute'])(
+ 'should preserve a caller tabindex set through the %s while disabled',
+ async mode => {
+ element.disabled = true;
+ await elementIsStable(element);
+ if (mode === 'property') element.tabIndex = -1;
+ else element.setAttribute('tabindex', '-1');
+ element.disabled = false;
+ element.value = { x: 0.2, y: 0.3 };
+ await elementIsStable(element);
+ expect(element.tabIndex).toBe(-1);
+ element.disabled = true;
+ await elementIsStable(element);
+ element.disabled = false;
+ await elementIsStable(element);
+ expect(element.tabIndex).toBe(-1);
+ }
+ );
+
+ it.each(['disabled', 'readOnly'] as const)(
+ 'should restore the caller touch action when %s and override it only while available',
+ async property => {
+ const other = document.createElement('nve-joystick');
+ other.style.setProperty('touch-action', 'pan-y', 'important');
+ other[property] = true;
+ fixture.append(other);
+ await elementIsStable(other);
+ expect(other.style.touchAction).toBe('pan-y');
+ expect(other.style.getPropertyPriority('touch-action')).toBe('important');
+ other[property] = false;
+ await elementIsStable(other);
+ expect(other.style.touchAction).toBe('none');
+ other[property] = true;
+ await elementIsStable(other);
+ expect(other.style.touchAction).toBe('pan-y');
+ expect(other.style.getPropertyPriority('touch-action')).toBe('important');
+ other[property] = false;
+ await elementIsStable(other);
+ expect(other.style.touchAction).toBe('none');
+ }
+ );
+
+ it('should update programmatic values without input events or attribute reflection', async () => {
+ const listener = vi.fn();
+ element.addEventListener('input', listener);
+ element.value = { x: 0.2, y: -0.4 };
+ await elementIsStable(element);
+ expect(element.getAttribute('value')).toBeNull();
+ expect(listener).not.toHaveBeenCalled();
+ expect(new FormData(form).get('movement-y')).toBe('-0.4');
+ expect(element.shadowRoot.querySelector('.thumb').style.left).toBe('60%');
+ });
+
+ it('should apply the opacity attribute to the background without fading the thumb or border', async () => {
+ const surface = element.shadowRoot.querySelector('[internal-host]');
+ const border = getComputedStyle(surface).border;
+ expect(element.opacity).toBe(false);
+ element.setAttribute('opacity', '');
+ await elementIsStable(element);
+ expect(element.opacity).toBe(true);
+ expect(getComputedStyle(element.shadowRoot.querySelector('.background')).opacity).toBe('0.6');
+ expect(getComputedStyle(element).opacity).toBe('1');
+ expect(getComputedStyle(surface).opacity).toBe('1');
+ expect(getComputedStyle(surface).border).toBe(border);
+ expect(getComputedStyle(surface, '::before').opacity).toBe('1');
+ expect(getComputedStyle(element.shadowRoot.querySelector('.thumb')).opacity).toBe('1');
+ });
+
+ it('should use the CSS opacity value only while the Boolean property is enabled', async () => {
+ const background = element.shadowRoot.querySelector('.background');
+ element.style.setProperty('--opacity', '0.35');
+ expect(getComputedStyle(background).opacity).toBe('1');
+ element.opacity = true;
+ await elementIsStable(element);
+ expect(element.hasAttribute('opacity')).toBe(true);
+ expect(getComputedStyle(background).opacity).toBe('0.35');
+ element.opacity = false;
+ await elementIsStable(element);
+ expect(element.hasAttribute('opacity')).toBe(false);
+ expect(getComputedStyle(background).opacity).toBe('1');
+ });
+
+ it('should reject malformed and out-of-constraint values without replacing the current vector', () => {
+ expect(() => {
+ element.value = { x: NaN, y: 0 };
+ }).toThrow(TypeError);
+ expect(() => {
+ element.value = { x: 1, y: 1 };
+ }).toThrow(TypeError);
+ expect(element.value).toEqual({ x: 0, y: 0 });
+ });
+
+ it('should expose the associated label as its accessible name', () => {
+ expect(element._internals.ariaLabel).toBe('Movement');
+ });
+
+ it('should preserve an explicit accessible name when the value changes', async () => {
+ element.ariaLabel = 'Recorded direction';
+ element.value = { x: 0.2, y: -0.4 };
+ await elementIsStable(element);
+ expect(element.ariaLabel).toBe('Recorded direction');
+ expect(element._internals.ariaLabel).toBe('Recorded direction');
+ });
+
+ it('should parse a square value regardless of attribute order', async () => {
+ const container = document.createElement('div');
+ container.innerHTML = ' ';
+ fixture.append(container);
+ const square = container.querySelector('nve-joystick');
+ await elementIsStable(square);
+ expect(square.value).toEqual({ x: 1, y: 1 });
+ });
+
+ it('should preserve the pointer direction beyond a circular boundary', () => {
+ element.dispatchEvent(pointer('pointerdown', 160, 110));
+ expect(element.value.x).toBeCloseTo(2 / Math.sqrt(5));
+ expect(element.value.y).toBeCloseTo(1 / Math.sqrt(5));
+ expect(element.matches(':state(active)')).toBe(true);
+ });
+
+ it.each(['square', 'horizontal', 'vertical'] as const)('should constrain pointer input to %s', async constraint => {
+ element.constraint = constraint;
+ await elementIsStable(element);
+ element.dispatchEvent(pointer('pointerdown', 110, 110));
+ expect(element.value).toEqual({ x: constraint === 'vertical' ? 0 : 1, y: constraint === 'horizontal' ? 0 : 1 });
+ });
+
+ it('should emit a final neutral input before change and the terminal event', () => {
+ const order: string[] = [];
+ for (const type of ['interaction-start', 'input', 'change', 'interaction-end']) {
+ element.addEventListener(type, () => order.push(type));
+ }
+ element.dispatchEvent(pointer('pointerdown', 110, 60));
+ element.dispatchEvent(pointer('pointerup', 110, 60));
+ expect(element.value).toEqual({ x: 0, y: 0 });
+ expect(order).toEqual(['interaction-start', 'input', 'input', 'change', 'interaction-end']);
+ expect(element.matches(':state(active)')).toBe(false);
+ });
+
+ it('should preserve a held value after normal release', async () => {
+ element.release = 'hold';
+ await elementIsStable(element);
+ element.dispatchEvent(pointer('pointerdown', 110, 60));
+ element.dispatchEvent(pointer('pointerup', 110, 60));
+ expect(element.value).toEqual({ x: 1, y: 0 });
+ expect(element.matches(':state(active)')).toBe(false);
+ });
+
+ it('should set the active custom state at neutral and before lifecycle events without an attribute', () => {
+ const states: boolean[] = [];
+ element.addEventListener('interaction-start', () => states.push(element.matches(':state(active)')));
+ element.addEventListener('interaction-end', () => states.push(element.matches(':state(active)')));
+ expect(element.matches(':state(active)')).toBe(false);
+ element.dispatchEvent(pointer('pointerdown', 60, 60));
+ expect(element.value).toEqual({ x: 0, y: 0 });
+ expect(element.matches(':state(active)')).toBe(true);
+ expect(element.hasAttribute('active')).toBe(false);
+ element.dispatchEvent(pointer('pointerup', 60, 60));
+ expect(element.matches(':state(active)')).toBe(false);
+ expect(states).toEqual([true, false]);
+ element.value = undefined;
+ expect(element.matches(':state(active)')).toBe(false);
+ });
+
+ it('should include the release position when the pointer moves between samples', async () => {
+ element.release = 'hold';
+ await elementIsStable(element);
+ element.dispatchEvent(pointer('pointerdown', 110, 60));
+ element.dispatchEvent(pointer('pointerup', 60, 10));
+ expect(element.value).toEqual({ x: 0, y: -1 });
+ });
+
+ it.each(['pointercancel', 'lostpointercapture'] as const)(
+ 'should neutralize held input on %s exactly once',
+ async type => {
+ element.release = 'hold';
+ await elementIsStable(element);
+ const ended = vi.fn();
+ const changed = vi.fn();
+ element.addEventListener('interaction-end', ended);
+ element.addEventListener('change', changed);
+ element.dispatchEvent(pointer('pointerdown', 110, 60));
+ element.dispatchEvent(pointer(type, 110, 60));
+ element.dispatchEvent(pointer('pointerup', 110, 60));
+ expect(element.value).toEqual({ x: 0, y: 0 });
+ expect(ended).toHaveBeenCalledOnce();
+ expect(element.matches(':state(active)')).toBe(false);
+ expect(changed).not.toHaveBeenCalled();
+ }
+ );
+
+ it('should ignore a second pointer and continue after the owning pointer releases', () => {
+ element.dispatchEvent(pointer('pointerdown', 110, 60));
+ element.dispatchEvent(pointer('pointerdown', 10, { clientY: 60, pointerId: 2 }));
+ element.dispatchEvent(pointer('pointerup', 10, { clientY: 60, pointerId: 2 }));
+ expect(element.value).toEqual({ x: 1, y: 0 });
+ element.dispatchEvent(pointer('pointerup', 110, 60));
+ element.dispatchEvent(pointer('pointerdown', 10, { clientY: 60, pointerId: 2 }));
+ expect(element.value).toEqual({ x: -1, y: 0 });
+ });
+
+ it('should ignore secondary mouse buttons', () => {
+ element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 2, pointerId: 1 }));
+ expect(element.matches(':state(active)')).toBe(false);
+ });
+
+ it('should accept a non-primary touch pointer for a separate control', () => {
+ element.dispatchEvent(
+ new PointerEvent('pointerdown', {
+ bubbles: true,
+ pointerId: 2,
+ isPrimary: false,
+ pointerType: 'touch',
+ button: 0,
+ clientX: 110,
+ clientY: 60
+ })
+ );
+ expect(element.value).toEqual({ x: 1, y: 0 });
+ });
+
+ it('should use physical directions in rtl layouts', async () => {
+ element.dir = 'rtl';
+ await elementIsStable(element);
+ element.dispatchEvent(pointer('pointerdown', 110, 60));
+ expect(element.value.x).toBe(1);
+ });
+
+ it('should keep two touch controls active without transferring focus', async () => {
+ const other = document.createElement('nve-joystick');
+ fixture.append(other);
+ await elementIsStable(other);
+ vi.spyOn(other, 'setPointerCapture').mockImplementation(() => {});
+ vi.spyOn(other.shadowRoot.querySelector('.travel'), 'getBoundingClientRect').mockReturnValue(
+ new DOMRect(10, 10, 100, 100)
+ );
+ element.focus();
+ const touch = (pointerId: number) =>
+ new PointerEvent('pointerdown', {
+ bubbles: true,
+ cancelable: true,
+ pointerId,
+ pointerType: 'touch',
+ button: 0,
+ clientX: 110,
+ clientY: 60
+ });
+ const first = touch(1);
+ const second = touch(2);
+ element.dispatchEvent(first);
+ other.dispatchEvent(second);
+ expect(first.defaultPrevented).toBe(true);
+ expect(second.defaultPrevented).toBe(true);
+ expect(document.activeElement).toBe(element);
+ expect(element.value).toEqual({ x: 1, y: 0 });
+ expect(other.value).toEqual({ x: 1, y: 0 });
+ element.dispatchEvent(pointer('pointerup', 110, 60));
+ expect(other.matches(':state(active)')).toBe(true);
+ other.dispatchEvent(pointer('pointerup', 110, { clientY: 60, pointerId: 2 }));
+ expect(other.value).toEqual({ x: 0, y: 0 });
+ });
+
+ it('should support diagonal keyboard input and wait for the final key release', () => {
+ key('keydown', 'ArrowRight');
+ key('keydown', 'ArrowUp');
+ expect(element.value).toEqual({ x: 0.1, y: -0.1 });
+ key('keyup', 'ArrowRight');
+ expect(element.matches(':state(active)')).toBe(true);
+ key('keyup', 'ArrowUp');
+ expect(element.value).toEqual({ x: 0, y: 0 });
+ });
+
+ it('should use keyboard-step and ignore locked-axis keys', async () => {
+ element.constraint = 'horizontal';
+ element.keyboardStep = 0.25;
+ await elementIsStable(element);
+ key('keydown', 'ArrowUp');
+ expect(element.matches(':state(active)')).toBe(false);
+ key('keydown', 'ArrowRight');
+ expect(element.value).toEqual({ x: 0.25, y: 0 });
+ });
+
+ it('should cancel keyboard input on escape without a change event', () => {
+ const end = vi.fn();
+ element.addEventListener('interaction-end', end);
+ key('keydown', 'ArrowRight');
+ key('keydown', 'Escape');
+ expect(element.value).toEqual({ x: 0, y: 0 });
+ expect(end.mock.calls[0][0].detail.reason).toBe('escape');
+ });
+
+ it.each(['disabled', 'readOnly'] as const)(
+ 'should cancel active input when %s and block future input',
+ async property => {
+ key('keydown', 'ArrowRight');
+ element[property] = true;
+ await elementIsStable(element);
+ expect(element.value).toEqual({ x: 0, y: 0 });
+ key('keydown', 'ArrowRight');
+ element.dispatchEvent(pointer('pointerdown', 110, 60));
+ expect(element.matches(':state(active)')).toBe(false);
+ }
+ );
+
+ it.each(['pointer', 'keyboard'] as const)('should end %s input immediately when disabled', async source => {
+ if (source === 'pointer') element.dispatchEvent(pointer('pointerdown', 110, 60));
+ else key('keydown', 'ArrowRight');
+ const order: string[] = [];
+ element.addEventListener('input', () => {
+ expect(element.value).toEqual({ x: 0, y: 0 });
+ order.push('input');
+ });
+ element.addEventListener('interaction-end', (event: CustomEvent) => {
+ expect(event.detail.reason).toBe('disabled');
+ order.push('interaction-end');
+ });
+ const change = vi.fn();
+ element.addEventListener('change', change);
+ element.disabled = true;
+ expect(element.matches(':state(active)')).toBe(false);
+ expect(element.value).toEqual({ x: 0, y: 0 });
+ expect(order).toEqual(['input', 'interaction-end']);
+ element.disabled = false;
+ element.dispatchEvent(pointer('pointerup', 110, 60));
+ key('keyup', 'ArrowRight');
+ await elementIsStable(element);
+ expect(element.matches(':state(active)')).toBe(false);
+ expect(order).toEqual(['input', 'interaction-end']);
+ expect(change).not.toHaveBeenCalled();
+ });
+
+ it('should allow read-only replay without events and omit disabled values from submission', async () => {
+ const input = vi.fn();
+ element.addEventListener('input', input);
+ element.readOnly = true;
+ element.value = { x: 0.2, y: 0.3 };
+ await elementIsStable(element);
+ expect(new FormData(form).get('movement-x')).toBe('0.2');
+ expect(input).not.toHaveBeenCalled();
+ element.disabled = true;
+ await elementIsStable(element);
+ expect([...new FormData(form)]).toEqual([]);
+ });
+
+ it.each(['pointer', 'keyboard'] as const)(
+ 'should finish %s input before accepting read-only replay',
+ async source => {
+ if (source === 'pointer') element.dispatchEvent(pointer('pointerdown', 110, 60));
+ else key('keydown', 'ArrowRight');
+ const order: string[] = [];
+ element.addEventListener('input', () => {
+ expect(element.value).toEqual({ x: 0, y: 0 });
+ order.push('input');
+ });
+ element.addEventListener('interaction-end', (event: CustomEvent) => {
+ expect(event.detail.reason).toBe('readonly');
+ order.push('interaction-end');
+ });
+ const change = vi.fn();
+ element.addEventListener('change', change);
+ element.readOnly = true;
+ expect(element.matches(':state(active)')).toBe(false);
+ expect(order).toEqual(['input', 'interaction-end']);
+ element.value = { x: 0.2, y: 0.3 };
+ element.dispatchEvent(pointer('pointerup', 110, 60));
+ key('keyup', 'ArrowRight');
+ await elementIsStable(element);
+ expect(element.value).toEqual({ x: 0.2, y: 0.3 });
+ expect(new FormData(form).get('movement-x')).toBe('0.2');
+ expect(order).toEqual(['input', 'interaction-end']);
+ expect(change).not.toHaveBeenCalled();
+ }
+ );
+
+ it('should restore the declared form value without input or change events', async () => {
+ element.setAttribute('value', '{"x":0.2,"y":-0.3}');
+ await elementIsStable(element);
+ key('keydown', 'ArrowRight');
+ const input = vi.fn();
+ const change = vi.fn();
+ element.addEventListener('input', input);
+ element.addEventListener('change', change);
+ form.reset();
+ expect(element.value).toEqual({ x: 0.2, y: -0.3 });
+ expect(element.matches(':state(active)')).toBe(false);
+ expect(input).not.toHaveBeenCalled();
+ expect(change).not.toHaveBeenCalled();
+ });
+
+ it('should restore JSON state silently and ignore invalid saved state', () => {
+ const input = vi.fn();
+ element.addEventListener('input', input);
+ element.formStateRestoreCallback('{"x":0.3,"y":-0.2}', 'restore');
+ expect(element.value).toEqual({ x: 0.3, y: -0.2 });
+ element.formStateRestoreCallback('{broken', 'restore');
+ element.formStateRestoreCallback('{"x":9,"y":0}', 'restore');
+ expect(element.value).toEqual({ x: 0.3, y: -0.2 });
+ expect(input).not.toHaveBeenCalled();
+ });
+
+ it.each(['circle', 'horizontal', 'vertical'] as const)(
+ 'should project the declared default into %s on direct and form reset',
+ async constraint => {
+ element.constraint = 'square';
+ await elementIsStable(element);
+ element.setAttribute('value', '{"x":1,"y":1}');
+ element.constraint = constraint;
+ await elementIsStable(element);
+ const input = vi.fn();
+ const change = vi.fn();
+ const reset = vi.fn(() => {
+ expect(() => {
+ element.value = { x: 1, y: 1 };
+ }).toThrow(TypeError);
+ });
+ element.addEventListener('input', input);
+ element.addEventListener('change', change);
+ element.addEventListener('reset', reset);
+ const expected = {
+ x: constraint === 'vertical' ? 0 : constraint === 'circle' ? Math.SQRT1_2 : 1,
+ y: constraint === 'horizontal' ? 0 : constraint === 'circle' ? Math.SQRT1_2 : 1
+ };
+ element.value = { x: 0, y: 0 };
+ element.reset();
+ await elementIsStable(element);
+ expect(element.value.x).toBeCloseTo(expected.x);
+ expect(element.value.y).toBeCloseTo(expected.y);
+ element.value = { x: 0, y: 0 };
+ form.reset();
+ await elementIsStable(element);
+ expect(element.value.x).toBeCloseTo(expected.x);
+ expect(element.value.y).toBeCloseTo(expected.y);
+ expect(Number(new FormData(form).get('movement-x'))).toBeCloseTo(expected.x);
+ expect(element.defaultValue).toBe('{"x":1,"y":1}');
+ expect(reset).toHaveBeenCalledTimes(2);
+ expect(input).not.toHaveBeenCalled();
+ expect(change).not.toHaveBeenCalled();
+ }
+ );
+
+ it('should distinguish missing samples from neutral values', async () => {
+ element.required = true;
+ expect(element.checkValidity()).toBe(true);
+ element.value = undefined;
+ await elementIsStable(element);
+ expect(element.validity.valueMissing).toBe(true);
+ expect(element.shadowRoot.querySelector('.thumb').hidden).toBe(true);
+ expect([...new FormData(form)]).toEqual([]);
+ });
+
+ it('should clear input on blur and disconnect without a stale terminal event', () => {
+ const reasons: JoystickInteractionEnd['reason'][] = [];
+ element.addEventListener('interaction-end', (event: CustomEvent) =>
+ reasons.push(event.detail.reason)
+ );
+ key('keydown', 'ArrowRight');
+ element.dispatchEvent(new Event('blur'));
+ key('keyup', 'ArrowRight');
+ key('keydown', 'ArrowRight');
+ element.remove();
+ expect(element.value).toEqual({ x: 0, y: 0 });
+ expect(reasons).toEqual(['blur', 'disconnect']);
+ });
+
+ it('should stop on page hiding and ignore subsequent stale movement', () => {
+ const ended = vi.fn();
+ element.addEventListener('interaction-end', ended);
+ element.dispatchEvent(pointer('pointerdown', 110, 60));
+ vi.spyOn(document, 'hidden', 'get').mockReturnValue(true);
+ document.dispatchEvent(new Event('visibilitychange'));
+ element.dispatchEvent(pointer('pointermove', 10, 60));
+ expect(element.value).toEqual({ x: 0, y: 0 });
+ expect(ended.mock.calls[0][0].detail.reason).toBe('hidden');
+ });
+
+ it('should accept a new pointer interaction after disconnecting during a drag', async () => {
+ vi.spyOn(element, 'focus').mockImplementation(() => {});
+ const ended = vi.fn();
+ element.addEventListener('interaction-end', ended);
+ element.dispatchEvent(pointer('pointerdown', 110, 60));
+ element.remove();
+ expect(element.value).toEqual({ x: 0, y: 0 });
+ expect(ended.mock.calls[0][0].detail.reason).toBe('disconnect');
+ form.append(element);
+ await elementIsStable(element);
+ element.dispatchEvent(pointer('pointermove', 10, 60));
+ element.dispatchEvent(pointer('pointerup', 10, 60));
+ expect(element.matches(':state(active)')).toBe(false);
+ element.dispatchEvent(pointer('pointerdown', 10, { clientY: 60, pointerId: 2 }));
+ expect(element.value).toEqual({ x: -1, y: 0 });
+ expect(element.matches(':state(active)')).toBe(true);
+ element.dispatchEvent(pointer('pointerup', 10, { clientY: 60, pointerId: 2 }));
+ expect(element.value).toEqual({ x: 0, y: 0 });
+ expect(ended).toHaveBeenCalledTimes(2);
+ expect(ended.mock.calls[1][0].detail.reason).toBe('release');
+ });
+
+ it('should cancel before accepting input if a start listener disables the control', () => {
+ element.addEventListener('interaction-start', () => {
+ element.disabled = true;
+ });
+ element.dispatchEvent(pointer('pointerdown', 110, 60));
+ expect(element.value).toEqual({ x: 0, y: 0 });
+ expect(element.matches(':state(active)')).toBe(false);
+ });
+
+ it('should project a value silently when its movement constraint changes', async () => {
+ element.constraint = 'square';
+ await elementIsStable(element);
+ element.value = { x: 1, y: 1 };
+ const listener = vi.fn();
+ element.addEventListener('input', listener);
+ element.constraint = 'circle';
+ await elementIsStable(element);
+ expect(element.value.x).toBeCloseTo(Math.SQRT1_2);
+ expect(element.value.y).toBeCloseTo(Math.SQRT1_2);
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ function key(type: string, keyValue: string) {
+ element.dispatchEvent(new KeyboardEvent(type, { key: keyValue, bubbles: true, cancelable: true }));
+ }
+});
+
+function pointer(type: string, clientX: number, position: number | { clientY: number; pointerId: number }) {
+ const { clientY, pointerId } = typeof position === 'number' ? { clientY: position, pointerId: 1 } : position;
+ return new PointerEvent(type, {
+ clientX,
+ clientY,
+ pointerId,
+ button: 0,
+ buttons: 1,
+ bubbles: true,
+ cancelable: true
+ });
+}
diff --git a/projects/core/src/joystick/joystick.test.visual.ts b/projects/core/src/joystick/joystick.test.visual.ts
new file mode 100644
index 0000000000..f5cdc44210
--- /dev/null
+++ b/projects/core/src/joystick/joystick.test.visual.ts
@@ -0,0 +1,127 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, expect, it } from 'vitest';
+import { visualRunner } from '@internals/vite';
+
+const sizes = [
+ { size: 'sm', surface: 104, thickness: 38.4, thumb: 19.2 },
+ { size: 'md', surface: 130, thickness: 48, thumb: 24 },
+ { size: 'lg', surface: 156, thickness: 57.6, thumb: 28.8 }
+];
+const constraints = ['circle', 'square', 'horizontal', 'vertical'];
+
+describe('joystick visual', () => {
+ it.each([
+ { theme: '', name: 'light' },
+ { theme: 'dark', name: 'dark' }
+ ])('should match visual baseline in $name theme', async ({ theme }) => {
+ await visualRunner.inspect(`joystick${theme ? '.dark' : ''}`, template(theme), async page => {
+ await page.waitForFunction(() => customElements.get('nve-joystick'));
+ for (const { size, surface, thickness, thumb } of sizes) {
+ for (const constraint of constraints) {
+ const control = page.locator(`[data-sizes] nve-joystick[size="${size}"][constraint="${constraint}"]`);
+ const bounds = await control.boundingBox();
+ expect(bounds?.width).toBeCloseTo(constraint === 'vertical' ? thickness : surface, 1);
+ expect(bounds?.height).toBeCloseTo(constraint === 'horizontal' ? thickness : surface, 1);
+ expect(
+ await control.locator('.thumb').evaluate(element => element.getBoundingClientRect().width)
+ ).toBeCloseTo(thumb, 1);
+ }
+ }
+ const control = page.locator('nve-joystick').first();
+ await control.evaluate(async element => {
+ element.size = 'lg';
+ await element.updateComplete;
+ });
+ expect(await control.getAttribute('size')).toBe('lg');
+ expect(await control.boundingBox()).toMatchObject({ width: 156 });
+ await control.evaluate(async element => {
+ element.size = 'sm';
+ await element.updateComplete;
+ });
+ await control.evaluate(element => element.style.setProperty('--width', '10rem'));
+ expect(await control.boundingBox()).toMatchObject({ width: 160 });
+ await control.evaluate(element => element.style.removeProperty('--width'));
+ const controls = page.locator('[data-sizes] > div:first-child > nve-joystick:nth-child(-n + 2)');
+ const travel = await controls.nth(0).boundingBox();
+ const turn = await controls.nth(1).boundingBox();
+ if (!travel || !turn) throw new Error('Joystick surfaces are missing.');
+ const client = await page.context().newCDPSession(page);
+ try {
+ const first = { id: 1, x: travel.x + travel.width * 0.75, y: travel.y + travel.height / 2 };
+ const second = { id: 2, x: turn.x + turn.width / 2, y: turn.y + turn.height * 0.25 };
+ await client.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [first] });
+ await client.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [first, second] });
+ expect(await controls.nth(0).evaluate(element => element.value.x)).toBeGreaterThan(0.4);
+ expect(await controls.nth(1).evaluate(element => element.value.y)).toBeLessThan(-0.4);
+ expect(
+ await controls.evaluateAll(elements => elements.every(element => element.matches(':state(active)')))
+ ).toBe(true);
+ await client.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
+ expect(await controls.nth(0).evaluate(element => element.value)).toEqual({ x: 0, y: 0 });
+ expect(await controls.nth(1).evaluate(element => element.value)).toEqual({ x: 0, y: 0 });
+ } finally {
+ await client.detach();
+ }
+ const surface = page.locator('nve-joystick').first();
+ const bounds = await surface.boundingBox();
+ if (!bounds) throw new Error('Joystick surface is missing.');
+ await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(bounds.x + bounds.width + 80, bounds.y + bounds.height / 2);
+ expect(await surface.evaluate(element => element.value.x)).toBeCloseTo(1);
+ await page.mouse.up();
+ expect(await surface.evaluate(element => element.value)).toEqual({ x: 0, y: 0 });
+ await surface.focus();
+ await page.keyboard.down('ArrowRight');
+ expect(await surface.evaluate(element => element.value.x)).toBeCloseTo(0.1);
+ await page.keyboard.up('ArrowRight');
+ expect(await surface.evaluate(element => element.value)).toEqual({ x: 0, y: 0 });
+ await surface.blur();
+ await page.mouse.move(0, 0);
+ });
+ });
+});
+
+function template(theme = '') {
+ return /* html */ `
+
+
+
+ ${sizes
+ .map(
+ ({ size }) => /* html */ `
+
+ ${constraints
+ .map(
+ constraint => /* html */ `
+
+ `
+ )
+ .join('')}
+
+ `
+ )
+ .join('')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+}
diff --git a/projects/core/src/joystick/joystick.ts b/projects/core/src/joystick/joystick.ts
new file mode 100644
index 0000000000..1fd303e426
--- /dev/null
+++ b/projects/core/src/joystick/joystick.ts
@@ -0,0 +1,595 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { html, isServer, LitElement, type PropertyValues } from 'lit';
+import { property } from 'lit/decorators/property.js';
+import { styleMap } from 'lit/directives/style-map.js';
+import { FormControlMixin } from '@nvidia-elements/forms/mixins';
+import {
+ GestureController,
+ useStyles,
+ type Gesture,
+ type GestureInput,
+ type Size
+} from '@nvidia-elements/core/internal';
+import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n';
+import styles from './joystick.css?inline';
+
+export interface JoystickValue {
+ readonly x: number;
+ readonly y: number;
+}
+
+export type JoystickConstraint = 'circle' | 'square' | 'horizontal' | 'vertical';
+export type JoystickRelease = 'center' | 'hold';
+
+export interface JoystickInteractionStart {
+ readonly source: 'pointer' | 'keyboard';
+}
+
+export interface JoystickInteractionEnd extends JoystickInteractionStart {
+ readonly reason:
+ | 'release'
+ | 'escape'
+ | 'pointer-cancel'
+ | 'lost-capture'
+ | 'blur'
+ | 'hidden'
+ | 'disabled'
+ | 'readonly'
+ | 'configuration-change'
+ | 'reset'
+ | 'disconnect';
+}
+
+type Session = JoystickInteractionStart & { pointerId?: number; changed: boolean };
+
+const i18nDefaults = {
+ joystickPosition: 'X {x}, Y {y}.',
+ joystickNoPosition: 'No position.',
+ joystickInstructions:
+ 'Use arrow keys to adjust the enabled axes. Release to return to center. Press Escape to cancel.',
+ joystickHoldInstructions:
+ 'Use arrow keys to adjust the enabled axes. Release to keep the position. Escape and interruptions return to center.',
+ joystickReturnedToCenter: '{position} Returned to center.'
+} satisfies I18nDefaults;
+
+const directions: Record = {
+ ArrowLeft: { x: -1, y: 0 },
+ ArrowRight: { x: 1, y: 0 },
+ ArrowUp: { x: 0, y: -1 },
+ ArrowDown: { x: 0, y: 1 }
+};
+
+/* eslint-disable jsdoc/no-types -- The metadata analyzer needs the inherited generic value type. */
+/**
+ * @element nve-joystick
+ * @description A joystick selects a normalized two-axis vector with pointer or keyboard input.
+ * Applications own physical units, coordinate frames, input shaping, publishing cadence, recording clocks,
+ * command-outcome feedback, and alternative controls when needed.
+ *
+ * Give each joystick an accessible name with a label, aria-label, or aria-labelledby.
+ * Editable controls use the application role to receive two-axis keyboard input.
+ * The default accessible description includes both normalized coordinates, keyboard instructions, and release behavior.
+ * A locked axis stays at zero; an absent value reads "No position." Disabled and read-only controls omit instructions.
+ * Coordinates use up to six decimal places and the document language for number formatting.
+ * An explicit aria-description or aria-describedby replaces the default focus description through standard ARIA precedence;
+ * it does not disable live announcements.
+ * @documentation https://nvidia.github.io/elements/docs/elements/joystick/
+ * @since 0.0.0
+ * @entrypoint \@nvidia-elements/core/joystick
+ * @property {JoystickValue | undefined} value - The normalized vector: positive x points right and positive y points down, independent of text direction. Assign a complete finite object within the selected constraint. Invalid vectors throw TypeError without replacing the previous value. Programmatic writes do not dispatch input or change events. Use undefined for an absent sample and a zero vector for recorded neutral input. Set readOnly before assigning replay ownership. User input can replace external writes during an interaction.
+ * @attribute {string} value - A JSON object containing normalized x and y coordinates.
+ * @event input - Dispatched after user input changes the vector, including a return to neutral. Read .value for the current vector. A held position does not generate repeated events. Publish commands at an application-controlled cadence.
+ * @event change - Dispatched after a normal interaction completes and produces input. Cancellation sends any necessary neutral input without change.
+ * @event interaction-start - Dispatched with the pointer or keyboard source after setting :state(active), including engagement at neutral.
+ * @event interaction-end - Dispatched with the source and terminal reason after clearing :state(active). Interruption first sends any necessary neutral input. Reset restores the declared default without value events. Detached events cannot bubble to former ancestors.
+ * @cssprop --width
+ * @cssprop --height
+ * @cssprop --background
+ * @cssprop --opacity - Background opacity when the opacity flag is present. Defaults to 0.6. The border, crosshair, thumb, and focus indicator keep their opacity.
+ * @cssprop --border
+ * @cssprop --thumb-width
+ * @cssprop --thumb-height
+ * @cssprop --thumb-background
+ * @cssprop --thumb-border
+ * @stable false
+ */
+export class Joystick extends FormControlMixin(LitElement) {
+ static styles = useStyles([styles]);
+
+ static readonly metadata = {
+ tag: 'nve-joystick',
+ version: '0.0.0',
+ valueSchema: {
+ type: 'object' as const,
+ properties: {
+ x: { type: 'number' as const, minimum: -1, maximum: 1 },
+ y: { type: 'number' as const, minimum: -1, maximum: 1 }
+ },
+ required: ['x', 'y'],
+ additionalProperties: false
+ }
+ };
+
+ /**
+ * Constrains the movement region or selects one axis. Circle limits vector magnitude to one;
+ * square permits both axes at full scale and a magnitude of up to Math.SQRT2.
+ * With equally scaled planar velocity axes, square corners allow about 41% greater speed.
+ * Mixed forward-velocity and turning-rate axes have no common physical magnitude.
+ * Changing the constraint projects the current value silently and cancels an active interaction.
+ */
+ @property({ type: String, reflect: true }) constraint: JoystickConstraint = 'circle';
+
+ /** Sets surface and thumb size. At the default font size, sm is 104px, md is 130px, and lg is 156px. */
+ @property({ type: String, reflect: true }) size: Size = 'md';
+
+ /** Enables background transparency using the --opacity CSS custom property. */
+ @property({ type: Boolean, reflect: true }) opacity = false;
+
+ /**
+ * Determines whether normal release returns to center or holds the final vector.
+ * Interrupted interactions return to neutral in either mode. Form reset restores the declared default instead.
+ */
+ @property({ type: String, reflect: true }) release: JoystickRelease = 'center';
+
+ /**
+ * Sets the normalized increment for each arrow key press. Repeated presses continue adjusting;
+ * releasing the final arrow applies the release behavior. Escape cancels the interaction.
+ * Invalid steps fall back to 0.1. Modifier shortcuts and locked-axis directions remain available to the browser.
+ */
+ @property({ type: Number, attribute: 'keyboard-step' }) keyboardStep = 0.1;
+
+ /**
+ * Controls automatic position announcements. Focused user input produces polite announcements at most every 500 ms.
+ * A quick tap in center mode reports the input coordinates and the return to center.
+ * Escape announces the resulting coordinates without a separate cancellation message.
+ * Programmatic writes and read-only playback update the description without live announcements.
+ * Set off when the application supplies spoken output, the position description on focus remains available.
+ */
+ @property({ type: String }) announce: 'polite' | 'off' = 'polite';
+
+ readonly #i18nController: I18nController = new I18nController(this, i18nDefaults);
+
+ /** Enables updating internal string values for internationalization. */
+ @property({ type: Object }) i18n = this.#i18nController.i18n;
+
+ #announcement = '';
+ #pendingFeedback?: { value: JoystickValue; kind: 'position' | 'return' };
+ #announcementTimer?: ReturnType;
+ #lastInput?: JoystickValue;
+ #writingInput = false;
+
+ readonly #gestureController = new GestureController(this, {
+ touchAction: () => (this.disabled || this.readOnly ? undefined : 'none'),
+ suppressNativeDrag: true
+ });
+ readonly #keys = new Set();
+ #session?: Session;
+ #resetting = false;
+ #ownsTabIndex = false;
+
+ static override get observedAttributes() {
+ return [...super.observedAttributes, 'tabindex'];
+ }
+
+ override attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
+ super.attributeChangedCallback(name, oldValue, newValue);
+ if (name === 'tabindex') this.#ownsTabIndex = false;
+ }
+
+ override get disabled(): boolean {
+ return super.disabled;
+ }
+
+ override set disabled(value: boolean | unknown) {
+ super.disabled = Boolean(value);
+ this.#checkAvailability();
+ }
+
+ override get readOnly(): boolean {
+ return super.readOnly;
+ }
+
+ override set readOnly(value: boolean | unknown) {
+ super.readOnly = Boolean(value);
+ this.#checkAvailability();
+ }
+
+ constructor() {
+ super();
+ this.value = { x: 0, y: 0 };
+ }
+
+ connectedCallback() {
+ const tabIndex = this.getAttribute('tabindex');
+ const ownsTabIndex = this.#ownsTabIndex || tabIndex === null;
+ super.connectedCallback();
+ // The forms mixin assigns tabindex on connection; keep the caller's focus order.
+ if (tabIndex !== null) this.setAttribute('tabindex', tabIndex);
+ this.#ownsTabIndex = ownsTabIndex;
+ if (this.hasUpdated) this.#gestureController.target = this;
+ this.addEventListener('nve-gesture-input', this.#handleGestureInput);
+ this.addEventListener('nve-gesture', this.#handleGesture);
+ this.addEventListener('keydown', this.#handleKeyDown);
+ this.addEventListener('keyup', this.#handleKeyUp);
+ this.addEventListener('blur', this.#handleBlur);
+ globalThis.window?.addEventListener('blur', this.#handleBlur);
+ globalThis.document?.addEventListener('visibilitychange', this.#handleVisibility);
+ }
+
+ disconnectedCallback() {
+ this.#end('disconnect');
+ this.#clearFeedback();
+ this.removeEventListener('nve-gesture-input', this.#handleGestureInput);
+ this.removeEventListener('nve-gesture', this.#handleGesture);
+ this.removeEventListener('keydown', this.#handleKeyDown);
+ this.removeEventListener('keyup', this.#handleKeyUp);
+ this.removeEventListener('blur', this.#handleBlur);
+ globalThis.window?.removeEventListener('blur', this.#handleBlur);
+ globalThis.document?.removeEventListener('visibilitychange', this.#handleVisibility);
+ super.disconnectedCallback();
+ }
+
+ firstUpdated() {
+ this.#gestureController.target = this;
+ }
+
+ willUpdate(changes: PropertyValues) {
+ // Lit SSR converts reactive attributes without invoking the forms mixin callback.
+ if (isServer && this.hasAttribute('value')) this._handleAttributeChange('value', this.getAttribute('value'));
+ this.#checkAvailability();
+ if (this.announce === 'off') this.#clearFeedback();
+ if (changes.has('constraint') || changes.has('release') || changes.has('keyboardStep')) {
+ this.#end('configuration-change');
+ this.#clearFeedback();
+ if (this.value) {
+ const value = this.#constrain(this.value);
+ if (!sameVector(this.value, value)) this.value = value;
+ }
+ }
+ }
+
+ updated() {
+ if (this.#ownsTabIndex || !this.hasAttribute('tabindex')) {
+ this.tabIndex = this.disabled ? -1 : 0;
+ this.#ownsTabIndex = true;
+ }
+ // Editable joysticks own the two-axis keyboard interaction, including release and cancellation.
+ this._internals.role = this.disabled || this.readOnly ? 'group' : 'application';
+ this._internals.ariaLabel = this.ariaLabel || this.composedLabel || null;
+ this._internals.ariaReadOnly = null;
+ this._internals.ariaDescription = this.#description;
+ }
+
+ get #description() {
+ const position = this.#describePosition(this.value);
+ if (this.disabled || this.readOnly) return position;
+ const instructions = this.release === 'hold' ? this.i18n.joystickHoldInstructions : this.i18n.joystickInstructions;
+ return `${position} ${instructions}`;
+ }
+
+ render() {
+ const value = this.value ?? { x: 0, y: 0 };
+ return html`
+
+ ${this.#announcement}
+ `;
+ }
+
+ override updateValue(value: JoystickValue) {
+ // Only project the default assignment; writes from reset listeners still use strict validation.
+ const nextValue = this.#resetting && isVector(value) ? this.#constrain(value) : value;
+ this.#resetting = false;
+ if (nextValue !== undefined && (!isVector(nextValue) || !sameVector(nextValue, this.#constrain(nextValue)))) {
+ throw new TypeError('Joystick value must contain finite x and y coordinates within its constraint.');
+ }
+ super.updateValue(nextValue);
+ if (!this.#writingInput) {
+ this.#lastInput = undefined;
+ this.#clearFeedback();
+ }
+ }
+
+ override reset() {
+ this.#end('reset');
+ this.#clearFeedback();
+ this.#resetting = true;
+ try {
+ super.reset();
+ } finally {
+ this.#resetting = false;
+ }
+ }
+
+ override formStateRestoreCallback(state: unknown, _reason?: string) {
+ if (typeof state !== 'string') return;
+ let value: unknown;
+ try {
+ value = state === '' ? undefined : JSON.parse(state);
+ } catch {
+ return;
+ }
+ if (value !== undefined && (!isVector(value) || !sameVector(value, this.#constrain(value)))) return;
+ this.#end('reset');
+ this.value = value;
+ }
+
+ override _updateFormState() {
+ if (!this.name || !this.value) {
+ this._internals.setFormValue(null, this.valueAsString);
+ return;
+ }
+ const data = new FormData();
+ data.set(`${this.name}-x`, `${this.value.x}`);
+ data.set(`${this.name}-y`, `${this.value.y}`);
+ this._internals.setFormValue(data, this.valueAsString);
+ }
+
+ #constrain({ x, y }: JoystickValue): JoystickValue {
+ const constraint = this.hasUpdated ? this.constraint : (this.getAttribute('constraint') ?? this.constraint);
+ if (constraint === 'circle') {
+ const length = Math.max(1, Math.hypot(x, y));
+ return { x: x / length, y: y / length };
+ }
+ return {
+ x: constraint === 'vertical' ? 0 : Math.max(-1, Math.min(1, x)),
+ y: constraint === 'horizontal' ? 0 : Math.max(-1, Math.min(1, y))
+ };
+ }
+
+ #begin(session: Session) {
+ this.#session = session;
+ this.#lastInput = undefined;
+ // A later interaction can report identical text after an empty region.
+ this.#announcement = '';
+ this.requestUpdate();
+ this._internals.states.add('active');
+ this.dispatchEvent(
+ new CustomEvent('interaction-start', {
+ bubbles: true,
+ composed: true,
+ detail: { source: session.source }
+ })
+ );
+ this.#checkAvailability();
+ }
+
+ #input(value: JoystickValue) {
+ if (sameVector(this.value, value)) return;
+ this.#writingInput = true;
+ try {
+ this.value = value;
+ } finally {
+ this.#writingInput = false;
+ }
+ if (this.#session) this.#lastInput = value;
+ if (this.#session) this.#session.changed = true;
+ this.dispatchInputEvent();
+ this.#checkAvailability();
+ if (this.#session && this.#lastInput === value) this.#queueFeedback(value, 'position');
+ }
+
+ #end(reason: JoystickInteractionEnd['reason']) {
+ const session = this.#session;
+ if (!session) return;
+ const lastInput = this.#lastInput;
+ this.#session = undefined;
+ this.#keys.clear();
+ this._internals.states.delete('active');
+ this.#releasePointer(session.pointerId);
+ if (reason !== 'reset' && (reason !== 'release' || this.release !== 'hold')) {
+ this.#input({ x: 0, y: 0 });
+ }
+ this.#finishFeedback(reason, this.#lastInput ? lastInput : undefined);
+ if (reason === 'release' && session.changed) this.dispatchChangeEvent();
+ this.dispatchEvent(
+ new CustomEvent('interaction-end', {
+ bubbles: true,
+ composed: true,
+ detail: { source: session.source, reason }
+ })
+ );
+ }
+
+ #finishFeedback(reason: JoystickInteractionEnd['reason'], lastInput: JoystickValue | undefined) {
+ if (reason === 'escape') {
+ if (sameVector(this.value, { x: 0, y: 0 })) this.#queueFeedback({ x: 0, y: 0 }, 'position');
+ } else if (reason === 'release' && lastInput) {
+ if (this.release === 'hold' && sameVector(this.value, lastInput)) this.#queueFeedback(lastInput, 'position');
+ else if (this.release !== 'hold' && sameVector(this.value, { x: 0, y: 0 }))
+ this.#queueFeedback(lastInput, 'return');
+ } else {
+ this.#clearFeedback();
+ }
+ }
+
+ #describePosition(value: JoystickValue | undefined): string {
+ if (!value) return this.i18n.joystickNoPosition ?? i18nDefaults.joystickNoPosition;
+ let formatter: Intl.NumberFormat;
+ const options = { maximumFractionDigits: 6, useGrouping: false };
+ try {
+ formatter = new Intl.NumberFormat(globalThis.document?.documentElement.lang || undefined, options);
+ } catch {
+ formatter = new Intl.NumberFormat(undefined, options);
+ }
+ const format = (number: number) => formatter.format(Number(number.toFixed(6)) || 0);
+ return (this.i18n.joystickPosition ?? '').replaceAll('{x}', format(value.x)).replaceAll('{y}', format(value.y));
+ }
+
+ get #canAnnounce() {
+ return (
+ this.isConnected &&
+ this.matches(':focus') &&
+ !this.disabled &&
+ !this.readOnly &&
+ this.announce !== 'off' &&
+ !globalThis.document.hidden
+ );
+ }
+
+ #queueFeedback(value: JoystickValue, kind: 'position' | 'return') {
+ if (!this.#canAnnounce) return;
+ this.#pendingFeedback = { value, kind };
+ if (this.#announcementTimer !== undefined) return;
+ this.#announcementTimer = setTimeout(() => {
+ this.#announcementTimer = undefined;
+ const feedback = this.#pendingFeedback;
+ this.#pendingFeedback = undefined;
+ if (!feedback || !this.#canAnnounce) return;
+ const position = this.#describePosition(feedback.value);
+ const message =
+ feedback.kind === 'return' ? this.i18n.joystickReturnedToCenter?.replaceAll('{position}', position) : position;
+ if (!message || message === this.#announcement) return;
+ this.#announcement = message;
+ this.requestUpdate();
+ }, 500);
+ }
+
+ #clearFeedback() {
+ clearTimeout(this.#announcementTimer);
+ this.#announcementTimer = undefined;
+ this.#pendingFeedback = undefined;
+ if (this.#announcement) {
+ this.#announcement = '';
+ this.requestUpdate();
+ }
+ }
+
+ #releasePointer(pointerId: number | undefined) {
+ if (pointerId === undefined) return;
+ if (this.hasPointerCapture(pointerId)) this.releasePointerCapture(pointerId);
+ this.#gestureController.target = undefined;
+ if (this.isConnected && this.hasUpdated) this.#gestureController.target = this;
+ }
+
+ #checkAvailability() {
+ if (this.disabled || this.readOnly) {
+ this.#end(this.disabled ? 'disabled' : 'readonly');
+ this.#clearFeedback();
+ }
+ }
+
+ #movePointer(event: PointerEvent) {
+ const bounds = this.shadowRoot?.querySelector('.travel')?.getBoundingClientRect();
+ if (!bounds || bounds.width <= 0 || bounds.height <= 0) return;
+ this.#input(
+ this.#constrain({
+ x: ((event.clientX - bounds.left) / bounds.width) * 2 - 1,
+ y: ((event.clientY - bounds.top) / bounds.height) * 2 - 1
+ })
+ );
+ }
+
+ #handleGestureInput = (event: Event) => {
+ if (!(event instanceof CustomEvent)) return;
+ const input: GestureInput = event.detail;
+ this.#checkAvailability();
+ if (input.kind === 'pointerdown') {
+ this.#startPointer(input);
+ } else if (input.kind === 'pointerend' && this.#session?.pointerId === input.event.pointerId) {
+ if (!input.interrupted) this.#movePointer(input.event);
+ this.#end(input.interrupted ? (input.reason === 'cancel' ? 'pointer-cancel' : 'lost-capture') : 'release');
+ }
+ };
+
+ #startPointer(input: Extract) {
+ if (!this.hasUpdated || this.disabled || this.readOnly || this.#session || input.event.button !== 0) return;
+ if (!input.claim({ kind: 'drag' })) return;
+ input.event.preventDefault();
+ // Touch controls can operate together without transferring keyboard focus.
+ if (input.event.pointerType !== 'touch') this.focus({ preventScroll: true });
+ this.#begin({ source: 'pointer', pointerId: input.event.pointerId, changed: false });
+ if (this.#session) this.#movePointer(input.event);
+ }
+
+ #handleGesture = (event: Event) => {
+ if (!(event instanceof CustomEvent)) return;
+ const gesture: Gesture = event.detail;
+ this.#checkAvailability();
+ if (gesture.kind === 'drag' && this.#session?.pointerId === gesture.event.pointerId) {
+ this.#movePointer(gesture.event);
+ }
+ };
+
+ #handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape' && this.#session) {
+ event.preventDefault();
+ this.#end('escape');
+ return;
+ }
+ const direction = directions[event.key];
+ if (!direction || !this.#acceptKey(event, direction)) return;
+ event.preventDefault();
+ this.#keys.add(event.key);
+ if (!this.#session) this.#begin({ source: 'keyboard', changed: false });
+ if (!this.#session) return;
+ this.#moveKey(direction);
+ };
+
+ #moveKey(direction: JoystickValue) {
+ const step = this.#step;
+ const value = this.value ?? { x: 0, y: 0 };
+ this.#input(
+ this.#constrain({
+ x: Number((value.x + direction.x * step).toFixed(6)),
+ y: Number((value.y + direction.y * step).toFixed(6))
+ })
+ );
+ }
+
+ get #step() {
+ return Number.isFinite(this.keyboardStep) && this.keyboardStep > 0 && this.keyboardStep <= 1
+ ? this.keyboardStep
+ : 0.1;
+ }
+
+ #acceptKey(event: KeyboardEvent, direction: JoystickValue) {
+ if (event.altKey || event.ctrlKey || event.metaKey || this.disabled || this.readOnly) return false;
+ if (this.#session?.source === 'pointer') return false;
+ if (this.constraint === 'horizontal') return direction.y === 0;
+ if (this.constraint === 'vertical') return direction.x === 0;
+ return true;
+ }
+
+ #handleKeyUp = (event: KeyboardEvent) => {
+ if (this.#session?.source !== 'keyboard' || !this.#keys.delete(event.key)) return;
+ event.preventDefault();
+ if (!this.#keys.size) this.#end('release');
+ };
+
+ #handleBlur = () => {
+ this.#checkAvailability();
+ this.#end('blur');
+ this.#clearFeedback();
+ };
+ #handleVisibility = () => {
+ if (globalThis.document.hidden) {
+ this.#end('hidden');
+ this.#clearFeedback();
+ }
+ };
+}
+
+function isVector(value: unknown): value is JoystickValue {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ 'x' in value &&
+ 'y' in value &&
+ typeof value.x === 'number' &&
+ typeof value.y === 'number' &&
+ Number.isFinite(value.x) &&
+ Number.isFinite(value.y) &&
+ Object.keys(value).length === 2
+ );
+}
+
+function sameVector(first: JoystickValue | undefined, second: JoystickValue): boolean {
+ return first !== undefined && Math.abs(first.x - second.x) < 1e-12 && Math.abs(first.y - second.y) < 1e-12;
+}
diff --git a/projects/foundations/src/internal/i18n/i18n.service.ts b/projects/foundations/src/internal/i18n/i18n.service.ts
index 833ae23df1..52ff8d5a61 100644
--- a/projects/foundations/src/internal/i18n/i18n.service.ts
+++ b/projects/foundations/src/internal/i18n/i18n.service.ts
@@ -60,6 +60,11 @@ export interface I18nStrings {
seekToStart: string;
unmuteMedia: string;
volume: string;
+ joystickPosition: string;
+ joystickNoPosition: string;
+ joystickInstructions: string;
+ joystickHoldInstructions: string;
+ joystickReturnedToCenter: string;
}
export type I18nDefaults = Readonly>;
diff --git a/projects/internals/metadata/static/tests.json b/projects/internals/metadata/static/tests.json
index 41f7b1148d..646c5fb202 100644
--- a/projects/internals/metadata/static/tests.json
+++ b/projects/internals/metadata/static/tests.json
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:34f6aed4d3abddd5ed594c7b342be0e042dc97df15c24af860d0be42236aa5c8
-size 3616928
+oid sha256:10919795a60e569085d98cc0fe8b2becee050d114dc650662c6863b159cc407c
+size 3818052
diff --git a/projects/site/src/_11ty/layouts/common.js b/projects/site/src/_11ty/layouts/common.js
index af99e73ba9..eae04dc384 100644
--- a/projects/site/src/_11ty/layouts/common.js
+++ b/projects/site/src/_11ty/layouts/common.js
@@ -269,6 +269,7 @@ export const renderDocsNav = data => /* html */ `
Iframe
Input
Input Group
+ Joystick
Logo
Markdown
Markdown CSS
diff --git a/projects/site/src/docs/elements/joystick.md b/projects/site/src/docs/elements/joystick.md
new file mode 100644
index 0000000000..8edd1359aa
--- /dev/null
+++ b/projects/site/src/docs/elements/joystick.md
@@ -0,0 +1,68 @@
+---
+{
+ title: 'Joystick',
+ description: 'Select a normalized two-axis vector for robot motion, simulation, and recorded-input inspection.',
+ layout: 'docs.11ty.js',
+ tag: 'nve-joystick'
+}
+---
+
+## Installation
+
+{% install 'nve-joystick' %}
+
+## Movement
+
+{% example '@nvidia-elements/core/joystick/joystick.examples.json' 'Default' %}
+
+## Constraints
+
+{% api 'nve-joystick', 'property', 'constraint' %}
+
+{% example '@nvidia-elements/core/joystick/joystick.examples.json' 'Constraints' %}
+
+## Size
+
+{% api 'nve-joystick', 'property', 'size' %}
+
+{% example '@nvidia-elements/core/joystick/joystick.examples.json' 'Size' %}
+
+## Opacity
+
+{% api 'nve-joystick', 'property', 'opacity' %}
+
+{% example '@nvidia-elements/core/joystick/joystick.examples.json' 'Opacity' %}
+
+## Travel Turn
+
+{% example '@nvidia-elements/core/joystick/joystick.examples.json' 'TravelTurn' %}
+
+## Input Shaping
+
+{% example '@nvidia-elements/core/joystick/joystick.examples.json' 'InputShaping' %}
+
+## Hold
+
+{% api 'nve-joystick', 'property', 'release' %}
+
+{% example '@nvidia-elements/core/joystick/joystick.examples.json' 'Hold' %}
+
+## Recorded Input
+
+{% example '@nvidia-elements/core/joystick/joystick.examples.json' 'States' %}
+
+## Forms
+
+{% example '@nvidia-elements/core/joystick/joystick.examples.json' 'Form' %}
+
+## Accessibility
+
+{% api 'nve-joystick', 'property', 'announce' %}
+
+{% example '@nvidia-elements/core/joystick/joystick.examples.json' 'ExternalFeedback' %}
+
+## Internationalization
+
+{% api 'nve-joystick', 'property', 'i18n' %}
+
+{% example '@nvidia-elements/core/joystick/joystick.examples.json' 'AccessibleFeedback' %}