diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0dd159ef..5d07aa4d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@ The following is a curated list of changes in the Enact samples module, newest c
## [unreleased]
+- Added `ui/spotlight-sandbox` sample demonstrating standalone `@enact/spotlight` usage
- Updated minor dependencies
## [2.7.0] - 2023-05-09
diff --git a/ui/spotlight-sandbox/.gitignore b/ui/spotlight-sandbox/.gitignore
new file mode 100644
index 00000000..f94ea516
--- /dev/null
+++ b/ui/spotlight-sandbox/.gitignore
@@ -0,0 +1,15 @@
+# See http://help.github.com/ignore-files/ for more about ignoring files.
+
+# dependencies
+node_modules
+
+# testing
+coverage
+
+# production
+build
+dist
+
+# misc
+.DS_Store
+npm-debug.log
diff --git a/ui/spotlight-sandbox/README.md b/ui/spotlight-sandbox/README.md
new file mode 100644
index 00000000..3cabfc1d
--- /dev/null
+++ b/ui/spotlight-sandbox/README.md
@@ -0,0 +1,21 @@
+## Spotlight Sandbox
+
+A sample Enact application that demonstrates standalone usage of `@enact/spotlight` for 5-way navigation and focus management.
+
+The sample includes:
+
+- **Container navigation** — nested spotlight containers with visual feedback when a container receives focus
+- **Sandbox** — draggable and resizable spottable controls to exercise spatial navigation
+- **Disappear sample** — focus behavior when controls are disabled, destroyed, or hidden
+- **Hold sample** — press-and-hold Enter on a focused control to trigger hold and pulse events
+- **Test page** — wide and tall controls for nearest-neighbor navigation testing
+
+Use the arrow keys on your keyboard to move focus between controls. Press Enter to activate a focused control.
+
+Each sample runs on its own page so 5-way navigation is not affected by other examples on screen.
+
+Run `npm install` then `npm run serve` to view the app at [http://localhost:8080](http://localhost:8080).
+
+---
+
+This project was bootstrapped with the Enact [cli](https://github.com/enactjs/cli).
diff --git a/ui/spotlight-sandbox/eslint.config.js b/ui/spotlight-sandbox/eslint.config.js
new file mode 100644
index 00000000..65598d08
--- /dev/null
+++ b/ui/spotlight-sandbox/eslint.config.js
@@ -0,0 +1,9 @@
+module.exports = [
+ {
+ ignores: [
+ 'node_modules/*',
+ 'build/*',
+ 'dist/*'
+ ]
+ }
+];
diff --git a/ui/spotlight-sandbox/package-lock.json b/ui/spotlight-sandbox/package-lock.json
new file mode 100644
index 00000000..85045f56
--- /dev/null
+++ b/ui/spotlight-sandbox/package-lock.json
@@ -0,0 +1,319 @@
+{
+ "name": "spotlight-sandbox",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "spotlight-sandbox",
+ "version": "1.0.0",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@enact/core": "^5.4.2",
+ "@enact/i18n": "^5.4.2",
+ "@enact/spotlight": "^5.4.2",
+ "@enact/ui": "^5.4.2",
+ "ilib": "^14.21.2",
+ "prop-types": "^15.8.1",
+ "ramda": "^0.32.0",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "react-router": "^7.13.1"
+ }
+ },
+ "node_modules/@enact/core": {
+ "version": "5.5.1",
+ "resolved": "https://registry.npmjs.org/@enact/core/-/core-5.5.1.tgz",
+ "integrity": "sha512-VA1xgb3b7SPH0M3tipVImo7xwStsxd/UOCaYrDwZQPzyAceRmMsPLUd6kSYy4sYwWfA0UR70XvktIhXreiLqkw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "classnames": "^2.5.1",
+ "invariant": "^2.2.4",
+ "prop-types": "^15.8.1",
+ "ramda": "^0.32.0",
+ "react": "^19.2.6",
+ "react-dom": "^19.2.6",
+ "react-is": "^19.2.6",
+ "warning": "^4.0.3"
+ }
+ },
+ "node_modules/@enact/i18n": {
+ "version": "5.5.1",
+ "resolved": "https://registry.npmjs.org/@enact/i18n/-/i18n-5.5.1.tgz",
+ "integrity": "sha512-QEyIOmL9G1zIxMgVbV3bPDrzcv4/mx0RXKqnklwb7QTmIqVyUrb9fedTU4ggpcFUfRD0Gf4MfFH+WdkwqfCSzQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@enact/core": "^5.5.1",
+ "prop-types": "^15.8.1",
+ "ramda": "^0.32.0",
+ "react": "^19.2.6",
+ "react-dom": "^19.2.6",
+ "xhr": "^2.6.0"
+ },
+ "peerDependencies": {
+ "ilib": "^14.21.0 || ^14.21.0-webos1"
+ }
+ },
+ "node_modules/@enact/spotlight": {
+ "version": "5.5.1",
+ "resolved": "https://registry.npmjs.org/@enact/spotlight/-/spotlight-5.5.1.tgz",
+ "integrity": "sha512-8LiFxQVVvCSWua0fs3wbvwXh6o9fRfc5u3gp9Z8tbksGzJpdZTrqb4YEUoRkOjOXzWrZv8YON6fjd7GPcFlKBw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@enact/core": "^5.5.1",
+ "classnames": "^2.5.1",
+ "prop-types": "^15.8.1",
+ "ramda": "^0.32.0",
+ "react": "^19.2.6",
+ "react-dom": "^19.2.6",
+ "warning": "^4.0.3"
+ }
+ },
+ "node_modules/@enact/ui": {
+ "version": "5.5.1",
+ "resolved": "https://registry.npmjs.org/@enact/ui/-/ui-5.5.1.tgz",
+ "integrity": "sha512-CjTCpklbCcfpEY91zrQPol8oMm/rf67wq9UqEmFSsw7kENqdOGaAR8Hcl1NDC8QmGmoneC/CtjsZUKznGf0zCA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@enact/core": "^5.5.1",
+ "@enact/i18n": "^5.5.1",
+ "classnames": "^2.5.1",
+ "ilib": "^14.22.0",
+ "invariant": "^2.2.4",
+ "prop-types": "^15.8.1",
+ "ramda": "^0.32.0",
+ "react": "^19.2.6",
+ "react-dom": "^19.2.6",
+ "react-is": "^19.2.6",
+ "warning": "^4.0.3"
+ }
+ },
+ "node_modules/classnames": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
+ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/dom-walk": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
+ "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
+ },
+ "node_modules/global": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
+ "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
+ "license": "MIT",
+ "dependencies": {
+ "min-document": "^2.19.0",
+ "process": "^0.11.10"
+ }
+ },
+ "node_modules/ilib": {
+ "version": "14.22.0",
+ "resolved": "https://registry.npmjs.org/ilib/-/ilib-14.22.0.tgz",
+ "integrity": "sha512-N3Rj+B1BwVPjhl5z67lKfTepGYos9Yfx5dosRDnh5pmVKv69eHYXyN5MG2yv3FRS9ilygSE8DEC7fm6Yleng6A==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8 <25"
+ }
+ },
+ "node_modules/invariant": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
+ "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.0.0"
+ }
+ },
+ "node_modules/is-function": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz",
+ "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==",
+ "license": "MIT"
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/min-document": {
+ "version": "2.19.2",
+ "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz",
+ "integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==",
+ "license": "MIT",
+ "dependencies": {
+ "dom-walk": "^0.1.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/parse-headers": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz",
+ "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
+ "license": "MIT"
+ },
+ "node_modules/process": {
+ "version": "0.11.10",
+ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
+ "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/prop-types/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
+ "node_modules/ramda": {
+ "version": "0.32.0",
+ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.32.0.tgz",
+ "integrity": "sha512-GQWAHhxhxWBWA8oIBr1XahFVjQ9Fic6MK9ikijfd4TZHfE2+urfk+irVlR5VOn48uwMgM+loRRBJd6Yjsbc0zQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/ramda"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.7"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
+ "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
+ "license": "MIT"
+ },
+ "node_modules/react-router": {
+ "version": "7.18.1",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
+ "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
+ "node_modules/warning": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
+ "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.0.0"
+ }
+ },
+ "node_modules/xhr": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/xhr/-/xhr-2.6.0.tgz",
+ "integrity": "sha512-/eCGLb5rxjx5e3mF1A7s+pLlR6CGyqWN91fv1JgER5mVWg1MZmlhBvy9kjcsOdRk8RrIujotWyJamfyrp+WIcA==",
+ "license": "MIT",
+ "dependencies": {
+ "global": "~4.4.0",
+ "is-function": "^1.0.1",
+ "parse-headers": "^2.0.0",
+ "xtend": "^4.0.0"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
diff --git a/ui/spotlight-sandbox/package.json b/ui/spotlight-sandbox/package.json
new file mode 100644
index 00000000..76df4633
--- /dev/null
+++ b/ui/spotlight-sandbox/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "spotlight-sandbox",
+ "version": "1.0.0",
+ "description": "An Enact sample demonstrating standalone @enact/spotlight 5-way navigation and focus.",
+ "author": "",
+ "main": "src/index.js",
+ "scripts": {
+ "serve": "enact serve",
+ "pack": "enact pack",
+ "pack-p": "enact pack -p",
+ "watch": "enact pack --watch",
+ "clean": "enact clean",
+ "lint": "enact lint --strict .",
+ "license": "enact license",
+ "test": "enact test",
+ "test-watch": "enact test --watch"
+ },
+ "license": "Apache-2.0",
+ "private": true,
+ "repository": "https://github.com/enactjs/samples",
+ "enact": {
+ "title": "Spotlight Sandbox",
+ "isomorphic": true,
+ "ri": {
+ "baseSize": 24
+ }
+ },
+ "dependencies": {
+ "@enact/core": "^5.4.2",
+ "@enact/i18n": "^5.4.2",
+ "@enact/spotlight": "^5.4.2",
+ "@enact/ui": "^5.4.2",
+ "ilib": "^14.21.2",
+ "prop-types": "^15.8.1",
+ "ramda": "^0.32.0",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "react-router": "^7.13.1"
+ }
+}
diff --git a/ui/spotlight-sandbox/resources/ilibmanifest.json b/ui/spotlight-sandbox/resources/ilibmanifest.json
new file mode 100644
index 00000000..d946318d
--- /dev/null
+++ b/ui/spotlight-sandbox/resources/ilibmanifest.json
@@ -0,0 +1,3 @@
+{
+ "files": []
+}
diff --git a/ui/spotlight-sandbox/src/App/App.js b/ui/spotlight-sandbox/src/App/App.js
new file mode 100644
index 00000000..1684868c
--- /dev/null
+++ b/ui/spotlight-sandbox/src/App/App.js
@@ -0,0 +1,41 @@
+import kind from '@enact/core/kind';
+import SpotlightRootDecorator from '@enact/spotlight/SpotlightRootDecorator';
+import {HashRouter, Route, Routes} from 'react-router';
+
+import {sampleRoutes} from '../routes';
+import MainView from '../views/MainView';
+import SamplePage from '../views/SamplePage';
+
+import css from './App.module.less';
+
+const AppBase = kind({
+ name: 'App',
+
+ styles: {
+ css,
+ className: 'app'
+ },
+
+ render: (props) => (
+
+
+
+ } />
+ {sampleRoutes.map(({path, title, Component}) => (
+
+
+
+ )}
+ />
+ ))}
+
+
+
+ )
+});
+
+export default SpotlightRootDecorator(AppBase);
diff --git a/ui/spotlight-sandbox/src/App/App.module.less b/ui/spotlight-sandbox/src/App/App.module.less
new file mode 100644
index 00000000..fe4dabf0
--- /dev/null
+++ b/ui/spotlight-sandbox/src/App/App.module.less
@@ -0,0 +1,33 @@
+@import '../styles/sampleTheme.less';
+
+:global {
+ html,
+ body,
+ #root {
+ height: 100%;
+ margin: 0;
+ overflow: hidden;
+ .sampleTypography();
+ }
+
+ h1 {
+ .sampleTitle();
+ }
+
+ p,
+ ul {
+ margin: 0;
+ }
+
+ ul {
+ padding: 0;
+ list-style: none;
+ }
+}
+
+.app {
+ height: 100%;
+ overflow: hidden;
+ position: relative;
+ background-color: #f1f5f9;
+}
diff --git a/ui/spotlight-sandbox/src/App/package.json b/ui/spotlight-sandbox/src/App/package.json
new file mode 100644
index 00000000..bf7e4816
--- /dev/null
+++ b/ui/spotlight-sandbox/src/App/package.json
@@ -0,0 +1,3 @@
+{
+ "main": "App.js"
+}
diff --git a/ui/spotlight-sandbox/src/components/DraggableControl.js b/ui/spotlight-sandbox/src/components/DraggableControl.js
new file mode 100644
index 00000000..a2c4d772
--- /dev/null
+++ b/ui/spotlight-sandbox/src/components/DraggableControl.js
@@ -0,0 +1,104 @@
+import Spottable from '@enact/spotlight/Spottable';
+import Spotlight from '@enact/spotlight';
+import PropTypes from 'prop-types';
+import {useCallback, useRef, useState} from 'react';
+
+import css from './DraggableControl.module.less';
+
+const SpottableDiv = Spottable('div');
+
+const CORNER_SIZE = 27;
+const DEFAULT_SIZE = 69;
+
+const DraggableControl = ({initialLeft = 0, initialTop = 0, label, ...rest}) => {
+ const [position, setPosition] = useState({left: initialLeft, top: initialTop});
+ const [size, setSize] = useState({width: DEFAULT_SIZE, height: DEFAULT_SIZE});
+ const dragState = useRef(null);
+
+ const handlePointerDown = useCallback((ev) => {
+ if (ev.button !== 0) {
+ return;
+ }
+
+ const bounds = ev.currentTarget.getBoundingClientRect();
+ const relativeRight = bounds.right - ev.clientX;
+ const relativeBottom = bounds.bottom - ev.clientY;
+ const resizing = relativeRight < CORNER_SIZE && relativeBottom < CORNER_SIZE;
+
+ dragState.current = {
+ resizing,
+ startX: ev.clientX,
+ startY: ev.clientY,
+ initialLeft: position.left,
+ initialTop: position.top,
+ initialWidth: size.width,
+ initialHeight: size.height
+ };
+
+ Spotlight.pause();
+ ev.currentTarget.setPointerCapture(ev.pointerId);
+ }, [position.left, position.top, size.height, size.width]);
+
+ const handlePointerMove = useCallback((ev) => {
+ if (!dragState.current) {
+ return;
+ }
+
+ const {resizing, startX, startY, initialLeft: startLeft, initialTop: startTop, initialWidth, initialHeight} = dragState.current;
+ const dx = ev.clientX - startX;
+ const dy = ev.clientY - startY;
+
+ if (resizing) {
+ setSize({
+ width: Math.max(CORNER_SIZE, initialWidth + dx),
+ height: Math.max(CORNER_SIZE, initialHeight + dy)
+ });
+ } else {
+ setPosition({
+ left: startLeft + dx,
+ top: startTop + dy
+ });
+ }
+ }, []);
+
+ const handlePointerUp = useCallback((ev) => {
+ if (!dragState.current) {
+ return;
+ }
+
+ dragState.current = null;
+ Spotlight.resume();
+
+ if (ev.currentTarget.hasPointerCapture(ev.pointerId)) {
+ ev.currentTarget.releasePointerCapture(ev.pointerId);
+ }
+ }, []);
+
+ return (
+
+ {label}
+
+
+ );
+};
+
+DraggableControl.propTypes = {
+ initialLeft: PropTypes.number,
+ initialTop: PropTypes.number,
+ label: PropTypes.oneOfType([PropTypes.number, PropTypes.string])
+};
+
+export default DraggableControl;
diff --git a/ui/spotlight-sandbox/src/components/DraggableControl.module.less b/ui/spotlight-sandbox/src/components/DraggableControl.module.less
new file mode 100644
index 00000000..b3b3e2ce
--- /dev/null
+++ b/ui/spotlight-sandbox/src/components/DraggableControl.module.less
@@ -0,0 +1,36 @@
+@import '../styles/sampleTheme.less';
+
+.control {
+ position: absolute;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid #10b981;
+ border-radius: 15px;
+ background:
+ linear-gradient(180deg, rgba(16, 185, 129, 0.18) 0%, rgba(16, 185, 129, 0.08) 100%);
+ color: @text;
+ font-family: @mono;
+ font-size: 18px;
+ font-weight: 700;
+ user-select: none;
+ touch-action: none;
+ box-shadow: 0 3px 3px rgba(15, 23, 42, 0.08);
+
+ &[data-spotlight-focused] {
+ border-color: @accent;
+ box-shadow: 0 0 0 3px @accent-soft;
+ }
+}
+
+.corner {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ width: 27px;
+ height: 27px;
+ border: 1px solid @border;
+ border-radius: 12px 0 15px 0;
+ background-color: @surface-muted;
+ cursor: nwse-resize;
+}
diff --git a/ui/spotlight-sandbox/src/components/HoldButton.js b/ui/spotlight-sandbox/src/components/HoldButton.js
new file mode 100644
index 00000000..ccbdcec1
--- /dev/null
+++ b/ui/spotlight-sandbox/src/components/HoldButton.js
@@ -0,0 +1,29 @@
+import kind from '@enact/core/kind';
+import Spottable from '@enact/spotlight/Spottable';
+import Touchable from '@enact/ui/Touchable';
+import compose from 'ramda/src/compose';
+
+import css from './SpottableButton.module.less';
+
+const HoldButtonBase = kind({
+ name: 'HoldButton',
+
+ styles: {
+ css,
+ className: 'button'
+ },
+
+ render: ({children, ...rest}) => (
+
+ {children}
+
+ )
+});
+
+// Touchable must wrap Spottable so Enter key emulation reaches Touchable's onMouseDown.
+const HoldButton = compose(
+ Touchable,
+ Spottable
+)(HoldButtonBase);
+
+export default HoldButton;
diff --git a/ui/spotlight-sandbox/src/components/SampleItem/SampleItem.js b/ui/spotlight-sandbox/src/components/SampleItem/SampleItem.js
new file mode 100644
index 00000000..b9255b98
--- /dev/null
+++ b/ui/spotlight-sandbox/src/components/SampleItem/SampleItem.js
@@ -0,0 +1,26 @@
+import PropTypes from 'prop-types';
+import {useCallback} from 'react';
+
+import SpottableButton from '../SpottableButton';
+
+import css from './SampleItem.module.less';
+
+const SampleItem = ({children, navigate, path, ...rest}) => {
+ const handleSelect = useCallback(() => {
+ navigate(path);
+ }, [navigate, path]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+SampleItem.propTypes = {
+ children: PropTypes.node,
+ navigate: PropTypes.func,
+ path: PropTypes.string
+};
+
+export default SampleItem;
diff --git a/ui/spotlight-sandbox/src/components/SampleItem/SampleItem.module.less b/ui/spotlight-sandbox/src/components/SampleItem/SampleItem.module.less
new file mode 100644
index 00000000..7fd4aacc
--- /dev/null
+++ b/ui/spotlight-sandbox/src/components/SampleItem/SampleItem.module.less
@@ -0,0 +1,36 @@
+@import '../../styles/sampleTheme.less';
+
+.item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ min-height: 78px;
+ padding: 21px 27px;
+ border: 1px solid @border;
+ border-radius: 21px;
+ background-color: @surface;
+ box-shadow: 0 3px 3px rgba(15, 23, 42, 0.04);
+ font-size: 21px;
+ font-weight: 600;
+ text-align: left;
+ transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
+
+ &::after {
+ content: '→';
+ color: @text-muted;
+ font-size: 24px;
+ transition: transform 160ms ease, color 160ms ease;
+ }
+
+ &[data-spotlight-focused] {
+ border-color: @accent;
+ box-shadow: 0 0 0 3px @accent-soft;
+ transform: translateY(-3px);
+
+ &::after {
+ color: @accent;
+ transform: translateX(6px);
+ }
+ }
+}
diff --git a/ui/spotlight-sandbox/src/components/SampleItem/package.json b/ui/spotlight-sandbox/src/components/SampleItem/package.json
new file mode 100644
index 00000000..97462035
--- /dev/null
+++ b/ui/spotlight-sandbox/src/components/SampleItem/package.json
@@ -0,0 +1,3 @@
+{
+ "main": "SampleItem.js"
+}
diff --git a/ui/spotlight-sandbox/src/components/SpottableButton.js b/ui/spotlight-sandbox/src/components/SpottableButton.js
new file mode 100644
index 00000000..59db9037
--- /dev/null
+++ b/ui/spotlight-sandbox/src/components/SpottableButton.js
@@ -0,0 +1,21 @@
+import kind from '@enact/core/kind';
+import Spottable from '@enact/spotlight/Spottable';
+
+import css from './SpottableButton.module.less';
+
+const SpottableButton = Spottable(kind({
+ name: 'SpottableButton',
+
+ styles: {
+ css,
+ className: 'button'
+ },
+
+ render: ({children, disabled, ...rest}) => (
+
+ {children}
+
+ )
+}));
+
+export default SpottableButton;
diff --git a/ui/spotlight-sandbox/src/components/SpottableButton.module.less b/ui/spotlight-sandbox/src/components/SpottableButton.module.less
new file mode 100644
index 00000000..c58d0987
--- /dev/null
+++ b/ui/spotlight-sandbox/src/components/SpottableButton.module.less
@@ -0,0 +1,46 @@
+@import '../styles/sampleTheme.less';
+
+.button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ min-width: 123px;
+ min-height: 63px;
+ height: auto;
+ padding: 15px 24px;
+ margin: 0;
+ border: 1px solid @border;
+ border-radius: 15px;
+ background-color: @surface;
+ color: @text;
+ font-size: 21px;
+ font-weight: 500;
+ line-height: 1.3;
+ text-align: center;
+ cursor: pointer;
+ white-space: normal;
+ box-shadow: 0 3px 3px rgba(15, 23, 42, 0.04);
+ transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
+
+ &[data-spotlight-focused] {
+ border-color: @accent;
+ box-shadow: 0 0 0 3px @accent-soft;
+ transform: translateY(-3px);
+ }
+
+ &[aria-disabled='true'] {
+ opacity: 0.45;
+ border-color: @border;
+ background-color: @surface-muted;
+ color: @text-muted;
+ cursor: not-allowed;
+ pointer-events: none;
+ transform: none;
+
+ &[data-spotlight-focused] {
+ border-color: @border;
+ box-shadow: none;
+ }
+ }
+}
diff --git a/ui/spotlight-sandbox/src/index.js b/ui/spotlight-sandbox/src/index.js
new file mode 100644
index 00000000..60509e6f
--- /dev/null
+++ b/ui/spotlight-sandbox/src/index.js
@@ -0,0 +1,18 @@
+/* global ENACT_PACK_ISOMORPHIC */
+import {createRoot, hydrateRoot} from 'react-dom/client';
+
+import App from './App';
+
+const appElement = ();
+
+if (typeof window !== 'undefined') {
+ const container = document.getElementById('root');
+
+ if (ENACT_PACK_ISOMORPHIC) {
+ hydrateRoot(container, appElement);
+ } else {
+ createRoot(container).render(appElement);
+ }
+}
+
+export default appElement;
diff --git a/ui/spotlight-sandbox/src/routes.js b/ui/spotlight-sandbox/src/routes.js
new file mode 100644
index 00000000..f2cf4844
--- /dev/null
+++ b/ui/spotlight-sandbox/src/routes.js
@@ -0,0 +1,13 @@
+import ContainerSample from './views/ContainerSample';
+import DisappearSample from './views/DisappearSample';
+import HoldSample from './views/HoldSample';
+import SandboxSample from './views/SandboxSample';
+import TestPage from './views/TestPage';
+
+export const sampleRoutes = [
+ {path: '/ContainerSample', title: 'Container Sample', Component: ContainerSample},
+ {path: '/SandboxSample', title: 'Sandbox Sample', Component: SandboxSample},
+ {path: '/DisappearSample', title: 'Disappear Sample', Component: DisappearSample},
+ {path: '/HoldSample', title: 'Hold Sample', Component: HoldSample},
+ {path: '/TestPage', title: 'Test Page', Component: TestPage}
+];
diff --git a/ui/spotlight-sandbox/src/styles/sampleTheme.less b/ui/spotlight-sandbox/src/styles/sampleTheme.less
new file mode 100644
index 00000000..91fe2853
--- /dev/null
+++ b/ui/spotlight-sandbox/src/styles/sampleTheme.less
@@ -0,0 +1,136 @@
+@surface: #fff;
+@surface-muted: #f8fafc;
+@border: #e2e8f0;
+@text: #0f172a;
+@text-muted: #64748b;
+@accent: #6366f1;
+@accent-soft: rgba(99, 102, 241, 0.12);
+@container-active: #f43f5e;
+@container-active-soft: rgba(244, 63, 94, 0.14);
+@test-wide: #0ea5e9;
+@test-wide-soft: rgba(14, 165, 233, 0.14);
+@test-tall: #8b5cf6;
+@test-tall-soft: rgba(139, 92, 246, 0.14);
+@shadow: 0 3px 3px rgba(15, 23, 42, 0.06), 0 12px 33px rgba(15, 23, 42, 0.06);
+@mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+@font-base: 21px;
+@font-title: 45px;
+@font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+
+.sampleTypography() {
+ font-family: @font-sans;
+ font-size: @font-base;
+ line-height: 1.6;
+ color: @text;
+}
+
+.sampleTitle() {
+ margin: 0 0 18px;
+ font-family: @font-sans;
+ font-size: @font-title;
+ font-weight: 700;
+ line-height: 1.2;
+ color: @text;
+}
+
+.sampleRoot() {
+ max-width: 1344px;
+}
+
+.sampleDescription() {
+ margin: 0 0 30px;
+ font-size: @font-base;
+ line-height: 1.6;
+ color: @text-muted;
+}
+
+.sampleDemoPanel() {
+ padding: 36px;
+ border: 1px solid @border;
+ border-radius: 24px;
+ background:
+ radial-gradient(circle at top left, rgba(99, 102, 241, 0.08), transparent 42%),
+ linear-gradient(180deg, @surface-muted 0%, @surface 100%);
+ box-shadow: @shadow;
+}
+
+.sampleStage() {
+ position: relative;
+ min-height: 504px;
+ border: 1px solid @border;
+ border-radius: 21px;
+ background-color: @surface;
+ box-shadow: inset 0 3px 3px rgba(15, 23, 42, 0.04);
+}
+
+.spotlightContainer() {
+ border: 1px solid @border;
+ border-radius: 21px;
+ background-color: @surface;
+ box-shadow: 0 3px 3px rgba(15, 23, 42, 0.04);
+ transition: border-color 160ms ease, box-shadow 160ms ease, background-color 160ms ease;
+
+ &:focus-within {
+ border-color: @container-active;
+ background-color: #fff7f8;
+ box-shadow:
+ 0 0 0 3px @container-active-soft,
+ 0 15px 33px rgba(244, 63, 94, 0.08);
+ }
+}
+
+.monoBadge() {
+ display: inline-flex;
+ align-self: flex-start;
+ padding: 6px 15px;
+ border-radius: 999px;
+ background-color: @accent-soft;
+ color: @accent;
+ font-family: @mono;
+ font-size: 18px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.statusBar() {
+ display: inline-flex;
+ align-items: center;
+ gap: 15px;
+ margin-top: 30px;
+ padding: 15px 21px;
+ border: 1px solid @border;
+ border-radius: 18px;
+ background-color: @surface;
+ box-shadow: @shadow;
+}
+
+.statusLabel() {
+ font-size: 18px;
+ font-weight: 600;
+ color: @text-muted;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.statusValue() {
+ padding: 6px 15px;
+ border-radius: 12px;
+ background-color: @surface-muted;
+ color: @text;
+ font-family: @mono;
+ font-size: 21px;
+}
+
+.statusCard() {
+ margin: 0;
+ padding: 18px 21px;
+ border: 1px solid @border;
+ border-radius: 18px;
+ background-color: @surface;
+ box-shadow: 0 3px 3px rgba(15, 23, 42, 0.04);
+ color: @text-muted;
+ font-size: 21px;
+ line-height: 1.5;
+ list-style: none;
+}
diff --git a/ui/spotlight-sandbox/src/views/ContainerSample.js b/ui/spotlight-sandbox/src/views/ContainerSample.js
new file mode 100644
index 00000000..42a8ab95
--- /dev/null
+++ b/ui/spotlight-sandbox/src/views/ContainerSample.js
@@ -0,0 +1,71 @@
+import Spotlight from '@enact/spotlight';
+import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
+import {useCallback, useState} from 'react';
+
+import SpottableButton from '../components/SpottableButton';
+
+import css from './ContainerSample.module.less';
+
+const Container = SpotlightContainerDecorator('div');
+
+const ContainerSample = () => {
+ const [activeContainer, setActiveContainer] = useState(null);
+
+ const handleFocusCapture = useCallback(() => {
+ setActiveContainer(Spotlight.getActiveContainer());
+ }, []);
+
+ const handleBlurCapture = useCallback((ev) => {
+ const layout = ev.currentTarget;
+
+ window.requestAnimationFrame(() => {
+ if (!layout.contains(document.activeElement)) {
+ setActiveContainer(null);
+ }
+ });
+ }, []);
+
+ return (
+
+
+ Nested spotlight containers. Use arrow keys to move between buttons.
+ Containers with focused descendants are highlighted.
+
+
+
+
+ c1
+
+ c1b1
+ c1b2
+
+
+
+ c2
+
+
c2b1
+
c2b2
+
+ c2c1
+
+ c2c1b1
+ c2c1b2
+
+
+
+
+
+
+
+ Active container
+ {activeContainer ?? 'none'}
+
+
+ );
+};
+
+export default ContainerSample;
diff --git a/ui/spotlight-sandbox/src/views/ContainerSample.module.less b/ui/spotlight-sandbox/src/views/ContainerSample.module.less
new file mode 100644
index 00000000..7eb71424
--- /dev/null
+++ b/ui/spotlight-sandbox/src/views/ContainerSample.module.less
@@ -0,0 +1,58 @@
+@import '../styles/sampleTheme.less';
+
+.containerSample {
+ .sampleRoot();
+}
+
+.description {
+ .sampleDescription();
+}
+
+.demoPanel {
+ .sampleDemoPanel();
+}
+
+.layout {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(393px, 1fr));
+ gap: 30px;
+ align-items: start;
+}
+
+.container {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+ min-width: 0;
+ padding: 24px;
+ .spotlightContainer();
+}
+
+.nested {
+ flex: 1 1 100%;
+ margin-top: 6px;
+ background-color: @surface-muted;
+}
+
+.containerLabel {
+ .monoBadge();
+}
+
+.controls {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 15px;
+}
+
+.statusBar {
+ .statusBar();
+}
+
+.statusLabel {
+ .statusLabel();
+}
+
+.statusValue {
+ .statusValue();
+}
diff --git a/ui/spotlight-sandbox/src/views/DisappearSample.js b/ui/spotlight-sandbox/src/views/DisappearSample.js
new file mode 100644
index 00000000..47059379
--- /dev/null
+++ b/ui/spotlight-sandbox/src/views/DisappearSample.js
@@ -0,0 +1,140 @@
+import Spotlight from '@enact/spotlight';
+import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
+import {useCallback, useLayoutEffect, useRef, useState} from 'react';
+
+import SpottableButton from '../components/SpottableButton';
+
+import css from './DisappearSample.module.less';
+
+const Container = SpotlightContainerDecorator('div');
+
+const DEFAULT_DISAPPEAR_TARGET = 'button02';
+
+const DisappearSample = () => {
+ const [button1Disabled, setButton1Disabled] = useState(false);
+ const [showInnerButton2, setShowInnerButton2] = useState(true);
+ const [showInnerButton3, setShowInnerButton3] = useState(true);
+ const [showC11, setShowC11] = useState(true);
+ const [hideC11, setHideC11] = useState(false);
+ const pendingFocusRef = useRef(false);
+
+ const focusDefaultDisappearTarget = useCallback(() => {
+ pendingFocusRef.current = true;
+ }, []);
+
+ useLayoutEffect(() => {
+ if (pendingFocusRef.current) {
+ pendingFocusRef.current = false;
+ Spotlight.focus(DEFAULT_DISAPPEAR_TARGET);
+ }
+ }, [showC11, hideC11]);
+
+ const handleRestore = useCallback(() => {
+ window.location.reload();
+ }, []);
+
+ const handleDisableButton1 = useCallback(() => {
+ setButton1Disabled(true);
+ }, []);
+
+ const handleDestroyInnerButton2 = useCallback(() => {
+ setShowInnerButton2(false);
+ }, []);
+
+ const handleHideInnerButton3 = useCallback(() => {
+ setShowInnerButton3(false);
+ }, []);
+
+ const handleDestroyAncestor = useCallback(() => {
+ focusDefaultDisappearTarget();
+ setShowC11(false);
+ }, [focusDefaultDisappearTarget]);
+
+ const handleHideAncestor = useCallback(() => {
+ focusDefaultDisappearTarget();
+ setHideC11(true);
+ }, [focusDefaultDisappearTarget]);
+
+ return (
+
+
+ Exercises focus behavior when spottable controls are disabled, destroyed, or hidden.
+ "Destroy My Ancestor" and "Hide My Ancestor" move focus to the
+ second button above (spotlightId: button02) after the inner container disappears.
+
+
+
+
+
+ I am first spottable of the app
+
+
+ I am defaultSpotlightDisappear for "Destroy My Ancestor"
+
+
+ Restore disappeared buttons
+
+
+ {showC11 ? (
+
+
+
+
+
+ Disable Me
+
+
+
+ {showInnerButton2 ? (
+
+ Destroy Me
+
+ ) : null}
+
+
+
+ Hide Me
+
+
+
+
+
+
+ Destroy My Ancestor
+
+
+
+
+ Hide My Ancestor
+
+
+
+
+
+ ) : null}
+
+
+
+ );
+};
+
+export default DisappearSample;
diff --git a/ui/spotlight-sandbox/src/views/DisappearSample.module.less b/ui/spotlight-sandbox/src/views/DisappearSample.module.less
new file mode 100644
index 00000000..177e599f
--- /dev/null
+++ b/ui/spotlight-sandbox/src/views/DisappearSample.module.less
@@ -0,0 +1,87 @@
+@import '../styles/sampleTheme.less';
+
+.disappearSample {
+ .sampleRoot();
+}
+
+.description {
+ .sampleDescription();
+}
+
+.demoPanel {
+ .sampleDemoPanel();
+}
+
+.rootContainer {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 30px;
+ padding: 6px;
+}
+
+.topRow {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 18px;
+ width: 100%;
+}
+
+.nestedContainer {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 21px;
+ width: 100%;
+ padding: 24px;
+ box-sizing: border-box;
+ border: 1px dashed @border;
+ border-radius: 21px;
+ background-color: @surface-muted;
+}
+
+.innerContainer {
+ display: flex;
+ flex-direction: column;
+ gap: 21px;
+}
+
+.actionRow {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 18px;
+ width: 100%;
+}
+
+.actionRowSecond {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 18px;
+ width: 100%;
+}
+
+.cell {
+ display: flex;
+ min-width: 0;
+}
+
+.cellButton {
+ width: 100%;
+}
+
+.hidden {
+ display: none !important;
+}
+
+@media (max-width: 900px) {
+ .topRow {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 720px) {
+ .actionRow,
+ .actionRowSecond {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/ui/spotlight-sandbox/src/views/HoldSample.js b/ui/spotlight-sandbox/src/views/HoldSample.js
new file mode 100644
index 00000000..d54fbd21
--- /dev/null
+++ b/ui/spotlight-sandbox/src/views/HoldSample.js
@@ -0,0 +1,96 @@
+import {useCallback, useEffect, useRef, useState} from 'react';
+
+import HoldButton from '../components/HoldButton';
+
+import css from './HoldSample.module.less';
+
+const idleMessages = {
+ hold: 'waiting for hold events...',
+ pulse: 'waiting for pulse events...',
+ other: 'waiting for other events...'
+};
+
+const holdConfig = {
+ frequency: 200,
+ events: [
+ {name: 'hold', time: 200},
+ {name: 'longpress', time: 1000},
+ {name: 'reallylongpress', time: 2000}
+ ]
+};
+
+const HoldSample = () => {
+ const [holdStatus, setHoldStatus] = useState(idleMessages.hold);
+ const [pulseStatus, setPulseStatus] = useState(idleMessages.pulse);
+ const [otherStatus, setOtherStatus] = useState(idleMessages.other);
+ const resetTimers = useRef({});
+
+ const report = useCallback((action, display) => {
+ const setters = {
+ hold: setHoldStatus,
+ pulse: setPulseStatus,
+ other: setOtherStatus
+ };
+
+ setters[display](`Select and Hold Me: ${action}`);
+
+ clearTimeout(resetTimers.current[display]);
+ resetTimers.current[display] = setTimeout(() => {
+ setters[display](idleMessages[display]);
+ }, 2000);
+ }, []);
+
+ useEffect(() => () => {
+ Object.values(resetTimers.current).forEach(clearTimeout);
+ }, []);
+
+ const handleHeld = useCallback((ev) => {
+ report(ev.name, 'hold');
+ }, [report]);
+
+ const handlePulse = useCallback((ev) => {
+ report(`pulsing (${Math.round(ev.time)})`, 'pulse');
+ }, [report]);
+
+ const handleReleased = useCallback(() => {
+ report('released', 'hold');
+ }, [report]);
+
+ const handleTapped = useCallback(() => {
+ report('tapped', 'other');
+ }, [report]);
+
+ return (
+
+
+ Focus the button with arrow keys, then press and hold Enter to trigger hold events.
+ Quick press Enter to tap. Hold events are provided by @enact/ui/Touchable on a
+ Spottable control.
+
+
+
+ Select and Hold Me
+
+
+ -
+ {holdStatus}
+
+ -
+ {pulseStatus}
+
+ -
+ {otherStatus}
+
+
+
+
+ );
+};
+
+export default HoldSample;
diff --git a/ui/spotlight-sandbox/src/views/HoldSample.module.less b/ui/spotlight-sandbox/src/views/HoldSample.module.less
new file mode 100644
index 00000000..d06556b1
--- /dev/null
+++ b/ui/spotlight-sandbox/src/views/HoldSample.module.less
@@ -0,0 +1,37 @@
+@import '../styles/sampleTheme.less';
+
+.holdSample {
+ .sampleRoot();
+}
+
+.description {
+ .sampleDescription();
+}
+
+.demoPanel {
+ .sampleDemoPanel();
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 30px;
+}
+
+.statusList {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(309px, 1fr));
+ gap: 18px;
+ width: 100%;
+ margin: 0;
+ padding: 0;
+}
+
+.statusItem {
+ .statusCard();
+}
+
+.statusItem em {
+ color: @text;
+ font-style: normal;
+ font-family: @mono;
+ font-size: 18px;
+}
diff --git a/ui/spotlight-sandbox/src/views/MainView.js b/ui/spotlight-sandbox/src/views/MainView.js
new file mode 100644
index 00000000..25e6cb49
--- /dev/null
+++ b/ui/spotlight-sandbox/src/views/MainView.js
@@ -0,0 +1,31 @@
+import BodyText from '@enact/ui/BodyText';
+import Heading from '@enact/ui/Heading';
+import {useNavigate} from 'react-router';
+
+import SampleItem from '../components/SampleItem';
+import {sampleRoutes} from '../routes';
+
+import css from './MainView.module.less';
+
+const MainView = () => {
+ const navigate = useNavigate();
+
+ return (
+
+
Spotlight Sandbox
+
+ Select a sample to explore standalone @enact/spotlight behavior. Each sample
+ runs on its own page so 5-way navigation stays predictable.
+
+
+ {sampleRoutes.map(({path, title}) => (
+
+ {title}
+
+ ))}
+
+
+ );
+};
+
+export default MainView;
diff --git a/ui/spotlight-sandbox/src/views/MainView.module.less b/ui/spotlight-sandbox/src/views/MainView.module.less
new file mode 100644
index 00000000..2d3d4701
--- /dev/null
+++ b/ui/spotlight-sandbox/src/views/MainView.module.less
@@ -0,0 +1,26 @@
+@import '~@enact/ui/styles/mixins.less';
+@import '../styles/sampleTheme.less';
+
+.main {
+ background:
+ radial-gradient(circle at top, rgba(99, 102, 241, 0.08), transparent 34%),
+ #f1f5f9;
+ position: absolute;
+ .position(0);
+ padding: 57px clamp(33px, 4vw, 78px);
+ overflow: auto;
+}
+
+.intro {
+ max-width: 897px;
+ margin-block: 0 18px;
+ color: @text-muted;
+}
+
+.list {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+ max-width: 783px;
+ margin-top: 39px;
+}
diff --git a/ui/spotlight-sandbox/src/views/SamplePage.js b/ui/spotlight-sandbox/src/views/SamplePage.js
new file mode 100644
index 00000000..ef81cedf
--- /dev/null
+++ b/ui/spotlight-sandbox/src/views/SamplePage.js
@@ -0,0 +1,33 @@
+import Heading from '@enact/ui/Heading';
+import PropTypes from 'prop-types';
+import {useCallback} from 'react';
+import {useNavigate} from 'react-router';
+
+import SpottableButton from '../components/SpottableButton';
+
+import css from './SamplePage.module.less';
+
+const SamplePage = ({children, title}) => {
+ const navigate = useNavigate();
+
+ const handleBack = useCallback(() => {
+ navigate('/');
+ }, [navigate]);
+
+ return (
+
+
+ Back to Samples
+
+ {title}
+ {children}
+
+ );
+};
+
+SamplePage.propTypes = {
+ children: PropTypes.node,
+ title: PropTypes.string
+};
+
+export default SamplePage;
diff --git a/ui/spotlight-sandbox/src/views/SamplePage.module.less b/ui/spotlight-sandbox/src/views/SamplePage.module.less
new file mode 100644
index 00000000..5e3b793e
--- /dev/null
+++ b/ui/spotlight-sandbox/src/views/SamplePage.module.less
@@ -0,0 +1,16 @@
+@import '~@enact/ui/styles/mixins.less';
+@import '../styles/sampleTheme.less';
+
+.page {
+ background:
+ radial-gradient(circle at top, rgba(99, 102, 241, 0.08), transparent 34%),
+ #f1f5f9;
+ position: absolute;
+ .position(0);
+ padding: 57px clamp(33px, 4vw, 78px);
+ overflow: auto;
+}
+
+.backButton {
+ margin-bottom: 30px;
+}
diff --git a/ui/spotlight-sandbox/src/views/SandboxSample.js b/ui/spotlight-sandbox/src/views/SandboxSample.js
new file mode 100644
index 00000000..355d826b
--- /dev/null
+++ b/ui/spotlight-sandbox/src/views/SandboxSample.js
@@ -0,0 +1,62 @@
+import {useCallback, useState} from 'react';
+
+import DraggableControl from '../components/DraggableControl';
+import SpottableButton from '../components/SpottableButton';
+
+import css from './SandboxSample.module.less';
+
+const createInitialControls = () => {
+ const controls = [];
+ let id = 0;
+
+ for (let row = 0; row < 2; row++) {
+ for (let column = 0; column < 4; column++) {
+ controls.push({
+ id: `control-${id++}`,
+ left: 141 + column * 141,
+ top: 141 * (row + 1)
+ });
+ }
+ }
+
+ return controls;
+};
+
+const SandboxSample = () => {
+ const [controls, setControls] = useState(createInitialControls);
+
+ const handleAddControl = useCallback(() => {
+ setControls((current) => current.concat({
+ id: `control-${current.length}`,
+ left: 30 + (current.length % 8) * 99,
+ top: 393 + Math.floor(current.length / 8) * 99
+ }));
+ }, []);
+
+ return (
+
+
+ Draggable spottable controls arranged in a grid. Drag to reposition or use the
+ bottom-right corner to resize. Use arrow keys to navigate between controls after
+ repositioning them.
+
+
+
+ Add Control
+
+
+ {controls.map(({id, left, top}, index) => (
+
+ ))}
+
+
+
+ );
+};
+
+export default SandboxSample;
diff --git a/ui/spotlight-sandbox/src/views/SandboxSample.module.less b/ui/spotlight-sandbox/src/views/SandboxSample.module.less
new file mode 100644
index 00000000..8c2871c9
--- /dev/null
+++ b/ui/spotlight-sandbox/src/views/SandboxSample.module.less
@@ -0,0 +1,24 @@
+@import '../styles/sampleTheme.less';
+
+.sandboxSample {
+ .sampleRoot();
+}
+
+.description {
+ .sampleDescription();
+}
+
+.demoPanel {
+ .sampleDemoPanel();
+}
+
+.toolbar {
+ margin-bottom: 24px;
+}
+
+.stage {
+ .sampleStage();
+ min-height: 504px;
+ margin-top: 0;
+ overflow: auto;
+}
diff --git a/ui/spotlight-sandbox/src/views/TestPage.js b/ui/spotlight-sandbox/src/views/TestPage.js
new file mode 100644
index 00000000..df3a5671
--- /dev/null
+++ b/ui/spotlight-sandbox/src/views/TestPage.js
@@ -0,0 +1,38 @@
+import SpottableButton from '../components/SpottableButton';
+
+import css from './TestPage.module.less';
+
+const TestPage = () => (
+
+
+ Spatial navigation test layout with wide and tall controls. Use arrow keys to verify
+ Spotlight chooses the nearest neighbor across differently sized targets.
+
+
+
+
+
+ A
+
+
+ B
+
+
+ C
+
+
+ D
+
+
+ E
+
+
+ F
+
+
+
+
+
+);
+
+export default TestPage;
diff --git a/ui/spotlight-sandbox/src/views/TestPage.module.less b/ui/spotlight-sandbox/src/views/TestPage.module.less
new file mode 100644
index 00000000..210abdbb
--- /dev/null
+++ b/ui/spotlight-sandbox/src/views/TestPage.module.less
@@ -0,0 +1,61 @@
+@import '../styles/sampleTheme.less';
+
+@test-canvas-width: 1512px;
+@test-canvas-height: 1875px;
+
+.testPage {
+ .sampleRoot();
+}
+
+.description {
+ .sampleDescription();
+}
+
+.demoPanel {
+ .sampleDemoPanel();
+ padding: 24px;
+}
+
+.stage {
+ .sampleStage();
+ width: 100%;
+ max-height: calc(100vh - 336px);
+ min-height: 504px;
+ overflow: auto;
+}
+
+.canvas {
+ position: relative;
+ width: @test-canvas-width;
+ height: @test-canvas-height;
+}
+
+.button,
+.buttonA {
+ position: absolute;
+ margin: 0;
+ min-width: 0;
+ border-color: @container-active;
+ font-family: @mono;
+ font-weight: 700;
+}
+
+.bigItem {
+ border-color: @test-wide;
+ background-color: fade(@test-wide, 8%);
+
+ &[data-spotlight-focused] {
+ border-color: @test-wide;
+ box-shadow: 0 0 0 3px @test-wide-soft;
+ }
+}
+
+.bigItemVertical {
+ border-color: @test-tall;
+ background-color: fade(@test-tall, 8%);
+
+ &[data-spotlight-focused] {
+ border-color: @test-tall;
+ box-shadow: 0 0 0 3px @test-tall-soft;
+ }
+}
diff --git a/ui/spotlight-sandbox/webos-meta/appinfo.json b/ui/spotlight-sandbox/webos-meta/appinfo.json
new file mode 100644
index 00000000..0549700c
--- /dev/null
+++ b/ui/spotlight-sandbox/webos-meta/appinfo.json
@@ -0,0 +1,12 @@
+{
+ "id": "com.enactjs.app.spotlight-sandbox",
+ "version": "1.0.0",
+ "vendor": "LGE-SVL",
+ "type": "web",
+ "main": "index.html",
+ "title": "Spotlight Sandbox",
+ "icon": "icon.png",
+ "miniicon": "icon-mini.png",
+ "largeIcon": "icon-large.png",
+ "uiRevision": 2
+}
diff --git a/ui/spotlight-sandbox/webos-meta/icon-large.png b/ui/spotlight-sandbox/webos-meta/icon-large.png
new file mode 100644
index 00000000..d237e9fb
Binary files /dev/null and b/ui/spotlight-sandbox/webos-meta/icon-large.png differ
diff --git a/ui/spotlight-sandbox/webos-meta/icon-mini.png b/ui/spotlight-sandbox/webos-meta/icon-mini.png
new file mode 100644
index 00000000..9771fac4
Binary files /dev/null and b/ui/spotlight-sandbox/webos-meta/icon-mini.png differ
diff --git a/ui/spotlight-sandbox/webos-meta/icon.png b/ui/spotlight-sandbox/webos-meta/icon.png
new file mode 100644
index 00000000..e616273a
Binary files /dev/null and b/ui/spotlight-sandbox/webos-meta/icon.png differ