You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

423 lines
9.1 KiB

  1. <head>
  2. <meta name="viewport" content="width=device-width, initial-scale=1">
  3. <link href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet" />
  4. <style>
  5. * {
  6. box-sizing: border-box;
  7. }
  8. html,
  9. body {
  10. background: rebeccapurple;
  11. margin: 0;
  12. padding: 0;
  13. }
  14. [role="img"] {
  15. pointer-events: none;
  16. }
  17. </style>
  18. </head>
  19. <hello-launcher></hello-launcher>
  20. <script type="module">
  21. import tag from 'https://thelanding.page/tag/tag.js'
  22. const { html, css, set, get, on } = tag('hello-launcher')
  23. const modes = {
  24. welcome: 'welcome',
  25. alias: 'alias',
  26. context: 'context',
  27. store: 'store',
  28. download: 'download',
  29. settings: 'settings',
  30. home: 'home'
  31. }
  32. const actions = {
  33. goto: 'goto',
  34. next: 'next',
  35. back: 'back'
  36. }
  37. const strictModes = [modes.welcome, modes.alias, modes.home]
  38. const paginationActions = [actions.back, actions.next]
  39. on('click', '.next', action(actions.next))
  40. on('click', '.back', action(actions.back))
  41. on('click', '.welcome', goTo(modes.welcome))
  42. on('click', '.home', goTo(modes.home))
  43. on('click', '.store', goTo(modes.store))
  44. on('click', '.download', goTo(modes.download))
  45. on('click', '.settings', goTo(modes.settings))
  46. html(target => {
  47. const { id } = target
  48. const launcher = launcherById(id)
  49. const renderers = {
  50. [modes.welcome]: () => `
  51. <div class="card">
  52. <h2>Welcome</h2>
  53. <button data-id="${id}" class="next">
  54. Continue
  55. </button>
  56. </div>
  57. `,
  58. [modes.alias]: () => `
  59. <div class="card">
  60. <h2>Alias</h2>
  61. <button data-id="${id}" class="next">
  62. Continue
  63. </button>
  64. <button data-id="${id}" class="back">
  65. Go Back
  66. </button>
  67. </div>
  68. `,
  69. [modes.context]: () => `
  70. <div class="card">
  71. <h2>Context</h2>
  72. <button data-id="${id}" class="next">
  73. Continue
  74. </button>
  75. <button data-id="${id}" class="back">
  76. Go Back
  77. </button>
  78. </div>
  79. `,
  80. [modes.home]: () => `
  81. <div class="icons">
  82. <button data-id="${id}" class="store">
  83. <span role="img" aria-labelledby="Store">🏬</span>
  84. </button>
  85. <button data-id="${id}" class="download">
  86. <span role="img" aria-labelledby="Download">📥</span>
  87. </button>
  88. <button data-id="${id}" class="settings">
  89. <span role="img" aria-labelledby="Settings">⚙️</span>
  90. </button>
  91. </div>
  92. `,
  93. [modes.store]: () => `
  94. <first-party-app>
  95. Store
  96. </first-party-app>
  97. `,
  98. [modes.download]: () => `
  99. <first-party-app>
  100. Download
  101. </first-party-app>
  102. `,
  103. [modes.settings]: () => `
  104. <first-party-app>
  105. Settings<br/>
  106. <button data-id="${id}" class="welcome">
  107. Log out
  108. </button>
  109. </first-party-app>
  110. `,
  111. 'default': () => `
  112. <div class="card">
  113. <h2>Error...</h2>
  114. <button data-id="${id}" class="home">
  115. Go Home
  116. </button>
  117. </div>
  118. `
  119. }
  120. const { mode, nextMode } = launcher
  121. const view = (renderers[mode] || renderers['default'])()
  122. const fadeOut = nextMode && mode !== nextMode
  123. return `
  124. <div class="mode-${mode}">
  125. <transition class="${fadeOut ? 'out' : ''}" data-id="${id}">
  126. ${view}
  127. </transition>
  128. </div>
  129. <launch-home>
  130. ${showHomeButton(id, launcher)}
  131. </launch-home>
  132. `
  133. })
  134. function showHomeButton(id, launcher) {
  135. const { mode, nextMode, emojiLabel } = launcher
  136. const fadeOut = strictModes.includes(nextMode)
  137. return strictModes.includes(mode)
  138. ? ''
  139. : `<button class="launch-home home ${fadeOut ? 'out' : ''}" data-id="${id}">
  140. ${emojiLabel}
  141. </button>`
  142. }
  143. function transition({target}) {
  144. const { id } = target.dataset
  145. const { mode, nextMode, backMode } = launcherById(id)
  146. const currentMode = nextMode ? nextMode : mode
  147. const previousMode = mode !== backMode ? backMode : mode
  148. set({ mode: currentMode, backMode: previousMode }, merge(id))
  149. target.scrollTop = '0'
  150. document.activeElement.blur()
  151. }
  152. on('animationend', 'transition', transition)
  153. css(`
  154. & {
  155. background: white;
  156. display: block;
  157. position: relative;
  158. overflow: hidden;
  159. }
  160. & *:focus {
  161. border-radius: none;
  162. outline: 2px dashed orange;
  163. outline-offset: .5rem;
  164. }
  165. & [class^="mode-"] {
  166. display: grid;
  167. height: 100%;
  168. place-items: center;
  169. width: 100%;
  170. }
  171. & button {
  172. display: block;
  173. min-height: 3rem;
  174. margin: 1rem 0;
  175. width: 100%;
  176. }
  177. & launch-home {
  178. background: none;
  179. border: none;
  180. display: block;
  181. position: absolute;
  182. inset: auto auto auto 50%;
  183. transform: translate(-50%, -75%);
  184. }
  185. & launch-home button {
  186. animation: ease-in-out 250ms;
  187. animation-name: &-zoom-in, &-fade-in;
  188. background: transparent;
  189. border: 3px solid dodgerblue;
  190. border-radius: 100%;
  191. color: white;
  192. cursor: pointer;
  193. display: grid;
  194. font-size: 2rem;
  195. height: 4rem;
  196. padding: .25rem;
  197. place-content: start center;
  198. text-shadow: 0 0 5px dodgerblue;
  199. transition: background 250ms ease-in-out;
  200. width: 4rem;
  201. }
  202. & launch-home button:hover,
  203. & launch-home button:focus {
  204. background: dodgerblue;
  205. }
  206. & launch-home button:active {
  207. background: orange;
  208. }
  209. & launch-home button.out {
  210. animation: ease-in-out 100ms;
  211. animation-name: &-zoom-out, &-fade-out;
  212. }
  213. & first-party-app {
  214. background: rgba(0,0,0,.85);
  215. color: #fff;
  216. }
  217. & third-party-app {
  218. background: white;
  219. }
  220. & first-party-app,
  221. & third-party-app {
  222. display: block;
  223. padding: 1rem;
  224. height: 100%;
  225. width: 100%;
  226. }
  227. & transition {
  228. animation: &-fade-in ease-in-out 250ms;
  229. display: grid;
  230. height: 100%;
  231. place-items: center;
  232. width: 100%;
  233. }
  234. & transition.out {
  235. animation: &-fade-out ease-in-out 100ms;
  236. }
  237. & .mode-${modes.home},
  238. & .mode-${modes.store},
  239. & .mode-${modes.download},
  240. & .mode-${modes.settings} {
  241. background: dodgerblue;
  242. }
  243. & .icons {
  244. display: grid;
  245. height: 100%;
  246. gap: 1rem;
  247. grid-template-columns: repeat(auto-fill, 4rem);
  248. grid-template-rows: repeat(auto-fill, 4rem);
  249. padding: 1rem;
  250. width: 100%;
  251. }
  252. & .icons button {
  253. margin: 0;
  254. }
  255. & .mode-${modes.home} transition {
  256. animation-name: &-zoom-in, &-fade-in;
  257. }
  258. & .mode-${modes.home} transition.out {
  259. animation-name: &-zoom-out, &-fade-out;
  260. }
  261. @keyframes &-fade-in {
  262. 0% {
  263. opacity: 0;
  264. }
  265. 100% {
  266. opacity: 1;
  267. }
  268. }
  269. @keyframes &-fade-out {
  270. 0% {
  271. opacity: 1;
  272. }
  273. 100% {
  274. opacity: 0;
  275. }
  276. }
  277. @keyframes &-zoom-in {
  278. 0% {
  279. transform: scale(.9);
  280. }
  281. 100% {
  282. transform: scale(1);
  283. }
  284. }
  285. @keyframes &-zoom-out {
  286. 0% {
  287. transform: scale(1);
  288. }
  289. 100% {
  290. transform: scale(.9);
  291. }
  292. }
  293. `)
  294. css(`
  295. /* global styles */
  296. .card {
  297. background: white;
  298. border-radius: 20px;
  299. padding: 1em;
  300. max-width: 100%;
  301. min-width: 320px;
  302. }
  303. `)
  304. /* controller-like logic */
  305. const welcomePath = [
  306. modes.welcome,
  307. modes.alias,
  308. modes.context,
  309. modes.home,
  310. ]
  311. function messageStateMachine(message) {
  312. return ({target}) => {
  313. const { id } = target.dataset
  314. stateMachine(id, message)
  315. }
  316. }
  317. function goTo(mode) {
  318. return messageStateMachine({ action: actions.goto, mode })
  319. }
  320. function action(action) {
  321. return messageStateMachine({ action })
  322. }
  323. function stateMachine(id, message) {
  324. const { mode, backMode } = launcherById(id)
  325. const { action } = message
  326. function setMode(nextMode) {
  327. set({ nextMode }, merge(id))
  328. }
  329. if(action === actions.goto) {
  330. setMode(message.mode)
  331. return
  332. }
  333. if(action === actions.back && backMode) {
  334. setMode(backMode)
  335. return
  336. }
  337. const onTheWelcomePath = welcomePath.includes(mode) && paginationActions.includes(action)
  338. if(onTheWelcomePath) {
  339. const order = action === actions.next
  340. ? welcomePath
  341. : [...welcomePath].reverse()
  342. const nextIndex = order.indexOf(mode) + 1
  343. setMode(order[nextIndex])
  344. return
  345. }
  346. }
  347. /* model-like logic */
  348. const emptyLauncher = {
  349. applications: [],
  350. emoji: '🏠',
  351. emojiLabel: 'home',
  352. mode: 'welcome',
  353. nextMode: null,
  354. backMode: null
  355. }
  356. export function launcherById(id) {
  357. return get()[id] || emptyLauncher
  358. }
  359. function merge(id) {
  360. return function middleware(state, payload) {
  361. return {
  362. ...state,
  363. [id]: {
  364. ...emptyLauncher,
  365. ...state[id],
  366. ...payload
  367. }
  368. }
  369. }
  370. }
  371. </script>