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