commit b470fc9aa56e02bb5583ba9ac03e02fed09796b9 Author: Rockwell Schrock Date: Fri Dec 12 09:30:42 2025 -0500 Initial working demo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ffca9f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.log +.DS_Store +.vscode/ +.idea/ diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..6cadaa5 --- /dev/null +++ b/.npmignore @@ -0,0 +1,8 @@ +example/ +src/ +node_modules/ +.gitignore +*.log +.DS_Store +.vscode/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6b81089 --- /dev/null +++ b/README.md @@ -0,0 +1,439 @@ +# @trmnl/picker + +A lightweight, framework-agnostic JavaScript library for managing TRMNL device model and palette selection. + +## Features + +- Zero dependencies +- Vanilla JavaScript (ES6+) +- Event-driven architecture +- Support for both NPM and browser usage +- Minimal API surface +- TypeScript-friendly + +## Installation + +### NPM +```bash +npm install @trmnl/picker +``` + +### Browser (CDN) +```html + +``` + +### Browser (Local) +```html + +``` + +## Quick Start + +### 1. Create HTML Structure + +The library expects a form with specific element IDs: + +```html +
+ + + + + + + + + + + + + + +
+``` + +### 2. Initialize Picker + +#### Browser Usage +```html + + +``` + +#### NPM Module Usage +```javascript +import TRMNLPicker from '@trmnl/picker' + +// Fetch data from API +const modelsResponse = await fetch('/api/models') +const modelsData = await modelsResponse.json() +const models = modelsData.data // Adjust based on your API response structure + +const palettes = [ + { id: 'bw', name: 'Black & White', framework_class: 'palette-bw' }, + { id: '4c', name: '4-Color', framework_class: 'palette-4c' } +] + +// Initialize +const picker = new TRMNLPicker('picker-form', models, palettes) +``` + +### 3. Listen for Changes + +```javascript +document.getElementById('picker-form').addEventListener('changed', (event) => { + const { screenClasses, state } = event.detail + + console.log('Screen classes:', screenClasses) + // ['palette-bw', 'screen--trmnl_original', 'screen--2.9', 'screen--landscape', 'screen--1x'] + + console.log('State:', state) + // { model: {...}, palette: {...}, isPortrait: false, isDarkMode: false } + + // Apply classes to your screen elements + document.querySelectorAll('.screen').forEach(screen => { + const filtered = screen.className.split(' ') + .filter(c => !c.startsWith('screen--') || c.startsWith('screen--scale-')) + screen.className = [...filtered, ...screenClasses].join(' ') + }) +}) +``` + +## API Reference + +### Constructor + +```javascript +new TRMNLPicker(formId, models, palettes) +``` + +**Parameters:** + +- `formId` (string, required): ID of the form element containing picker controls +- `models` (array, required): Array of model objects from the `/api/models` endpoint +- `palettes` (array, required): Array of palette objects + +**Model Object Structure:** +```javascript +{ + name: 'trmnl_original', // Unique identifier + label: 'TRMNL Original', // Display name + size: '2.9', // Screen size + width: 800, // Width in pixels + height: 480, // Height in pixels + palette_ids: ['bw', '4c'] // Available palettes for this model +} +``` + +**Palette Object Structure:** +```javascript +{ + id: 'bw', // Palette identifier + name: 'Black & White', // Display name + framework_class: 'palette-bw' // CSS class for styling +} +``` + +### Methods + +#### `update(config)` + +Programmatically update picker state. + +```javascript +picker.update({ + modelName: 'trmnl_original', // Model name to select + paletteId: 'bw', // Palette ID to select + isPortrait: true, // Set portrait orientation + isDarkMode: false // Set dark mode +}) +``` + +**Parameters:** +- `config.modelName` (string, optional): Model name to select +- `config.paletteId` (string, optional): Palette ID to select +- `config.isPortrait` (boolean, optional): Set portrait orientation +- `config.isDarkMode` (boolean, optional): Enable/disable dark mode + +#### `getState()` + +Get current picker state. + +```javascript +const state = picker.getState() +``` + +**Returns:** +```javascript +{ + model: { + name: 'trmnl_original', + label: 'TRMNL Original', + size: '2.9', + width: 800, + height: 480 + }, + palette: { + id: 'bw', + name: 'Black & White', + framework_class: 'palette-bw' + }, + isPortrait: false, + isDarkMode: false, + screenClasses: [ + 'palette-bw', + 'screen--trmnl_original', + 'screen--2.9', + 'screen--landscape', + 'screen--1x' + ] +} +``` + +#### `destroy()` + +Clean up event listeners and references. Call this when removing the picker. + +```javascript +picker.destroy() +``` + +### Events + +#### `changed` Event + +Emitted when user changes any selection (model, palette, orientation, or dark mode). + +```javascript +document.getElementById('picker-form').addEventListener('changed', (event) => { + const { screenClasses, state } = event.detail + // Handle the change +}) +``` + +**Event Detail Structure:** +```javascript +{ + screenClasses: [ + 'palette-bw', + 'screen--trmnl_original', + 'screen--2.9', + 'screen--landscape', + 'screen--1x' + ], + state: { + model: { name, label, size, width, height }, + palette: { id, name, framework_class }, + isPortrait: false, + isDarkMode: false + } +} +``` + +## Screen Class Generation + +The library generates CSS classes in the following order: + +1. **Palette class**: From `palette.framework_class` (e.g., `palette-bw`) +2. **Model name**: `screen--{model.name}` (e.g., `screen--trmnl_original`) +3. **Model size**: `screen--{model.size}` (e.g., `screen--2.9`) +4. **Orientation**: `screen--portrait` or `screen--landscape` +5. **Scale**: Always `screen--1x` +6. **Dark mode** (conditional): `screen--dark-mode` (when enabled) + +## Form Elements + +The library expects the following elements within the form: + +### Required Elements +- `#model-select` - Model dropdown +- `#palette-select` - Palette dropdown + +### Optional Elements +- `#orientation-toggle` - Button to toggle portrait/landscape +- `#dark-mode-toggle` - Button to toggle dark mode +- `#reset-button` - Button to reset palette to model's default +- `[data-orientation-text]` - Text element showing current orientation +- `[data-dark-mode-text]` - Text element showing current mode + +## Examples + +### Complete Working Example + +See `example/index.html` for a complete working example with styling. + +### Applying Classes to Screen Elements + +```javascript +document.getElementById('picker-form').addEventListener('changed', (event) => { + const { screenClasses } = event.detail + + // Apply to all elements with class 'screen' + document.querySelectorAll('.screen').forEach(screen => { + // Remove old screen-- classes (except scale and no-bleed) + const currentClasses = screen.className.split(' ') + const filteredClasses = currentClasses.filter(c => { + if (!c.startsWith('screen--')) return true + if (c.startsWith('screen--scale-')) return true + if (c === 'screen--no-bleed') return true + return false + }) + + // Add new screen classes + filteredClasses.push(...screenClasses) + screen.className = filteredClasses.join(' ') + }) +}) +``` + +### Using with React + +```jsx +import { useEffect, useRef } from 'react' +import TRMNLPicker from '@trmnl/picker' + +function ScreenPicker({ models, palettes, onChange }) { + const pickerRef = useRef(null) + + useEffect(() => { + const picker = new TRMNLPicker('picker-form', models, palettes) + + const handleChange = (event) => { + onChange(event.detail) + } + + document.getElementById('picker-form').addEventListener('changed', handleChange) + + pickerRef.current = picker + + return () => { + picker.destroy() + document.getElementById('picker-form').removeEventListener('changed', handleChange) + } + }, [models, palettes, onChange]) + + return ( +
+ + + + + +
+ ) +} +``` + +## Migration from Stimulus Version + +If you're migrating from the Stimulus-based controller: + +### Key Differences + +**Removed Features:** +- No localStorage persistence (implement in your app if needed) +- No BroadcastChannel synchronization +- No automatic DOM manipulation of `.screen` elements +- No automatic page reload + +**Data Model Changes:** +- Use `model.name` instead of `model.keyname` +- API response field `name` maps directly (e.g., `"trmnl_original"`) + +### Migration Steps + +1. Remove Stimulus controller and data attributes +2. Add standard HTML form with required element IDs +3. Include `@trmnl/picker` library +4. Initialize picker with `new TRMNLPicker()` +5. Listen to `changed` event and apply classes manually +6. Implement localStorage persistence if needed (application concern) + +**Before (Stimulus):** +```erb +
+ +
+``` + +**After (Vanilla):** +```html +
+ +
+ + +``` + +## Browser Support + +- Modern browsers with ES6+ support +- Chrome 51+ +- Firefox 54+ +- Safari 10+ +- Edge 15+ + +## Development + +```bash +# Install dependencies +npm install + +# Build all formats +npm run build + +# Build and watch for changes +npm run watch + +# Build specific formats +npm run build:esm # ES module +npm run build:browser # IIFE browser bundle +npm run build:browser:min # Minified browser bundle +``` + +## License + +MIT + +## Contributing + +Contributions are welcome! Please open an issue or submit a pull request. + +## Support + +For issues and questions, please use the [GitHub issue tracker](https://github.com/trmnl/trmnl-picker/issues). diff --git a/example/index.html b/example/index.html new file mode 100644 index 0000000..dd2d71f --- /dev/null +++ b/example/index.html @@ -0,0 +1,337 @@ + + + + + + TRMNL Picker - Browser Example + + + +
+

@trmnl/picker - Browser Example

+

Vanilla JavaScript library for TRMNL device model and palette selection

+ +
+
+ + + + +
+ + + + + +
+ + +
+
+ +
+

Event Output 0 events

+
+
Waiting for changes...
+
+
+ +
+

Screen Preview

+
+
+ TRMNL Screen Preview +
+
+
+
+ + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ca47fa5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,446 @@ +{ + "name": "@trmnl/picker", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@trmnl/picker", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "esbuild": "^0.20.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6593f9b --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "@trmnl/picker", + "version": "1.0.0", + "description": "Vanilla JavaScript library for TRMNL device model and palette selection", + "main": "dist/trmnl-picker.js", + "module": "dist/trmnl-picker.esm.js", + "browser": "dist/trmnl-picker.min.js", + "files": [ + "dist", + "src", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "npm run build:esm && npm run build:browser && npm run build:browser:min", + "build:esm": "esbuild src/index.js --bundle --format=esm --outfile=dist/trmnl-picker.esm.js --sourcemap", + "build:browser": "esbuild src/index.js --bundle --format=iife --global-name=TRMNLPicker --outfile=dist/trmnl-picker.js --sourcemap --footer:js=\"TRMNLPicker=TRMNLPicker.default;\"", + "build:browser:min": "esbuild src/index.js --bundle --format=iife --global-name=TRMNLPicker --minify --outfile=dist/trmnl-picker.min.js --sourcemap --footer:js=\"TRMNLPicker=TRMNLPicker.default;\"", + "watch": "npm run build:browser -- --watch", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "trmnl", + "picker", + "screen", + "device", + "palette", + "vanilla-js" + ], + "author": "TRMNL", + "license": "MIT", + "devDependencies": { + "esbuild": "^0.20.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/trmnl/trmnl-picker.git" + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..12085cd --- /dev/null +++ b/src/index.js @@ -0,0 +1,465 @@ +/** + * TRMNLPicker - Vanilla JS library for TRMNL device and palette selection + * + * @class TRMNLPicker + * @param {string} formId - ID of the form element containing picker controls + * @param {Array} models - Array of model objects from API + * @param {Array} palettes - Array of palette objects + */ +class TRMNLPicker { + constructor(formId, models, palettes) { + // Validate inputs + if (!formId || typeof formId !== 'string') { + throw new Error('TRMNLPicker: formId must be a non-empty string') + } + + if (!Array.isArray(models) || models.length === 0) { + throw new Error('TRMNLPicker: models must be a non-empty array') + } + + if (!Array.isArray(palettes) || palettes.length === 0) { + throw new Error('TRMNLPicker: palettes must be a non-empty array') + } + + // Store references + this.formElement = document.getElementById(formId) + if (!this.formElement) { + throw new Error(`TRMNLPicker: Form element with id "${formId}" not found`) + } + + this.models = models + this.palettes = palettes + + // Initialize state + this.state = { + selectedModel: null, + selectedPalette: null, + isPortrait: false, + isDarkMode: false + } + + // Initialize DOM elements and bind events + this._initializeElements() + this._bindEvents() + + // Set initial state + this._setInitialState() + } + + /** + * Find and store references to form elements + * @private + */ + _initializeElements() { + this.elements = { + modelSelect: this.formElement.querySelector('#model-select'), + paletteSelect: this.formElement.querySelector('#palette-select'), + orientationToggle: this.formElement.querySelector('#orientation-toggle'), + darkModeToggle: this.formElement.querySelector('#dark-mode-toggle'), + resetButton: this.formElement.querySelector('#reset-button'), + + // Optional: UI indicator elements + orientationText: this.formElement.querySelector('[data-orientation-text]'), + darkModeText: this.formElement.querySelector('[data-dark-mode-text]') + } + + // Validate required elements + const required = ['modelSelect', 'paletteSelect'] + for (const key of required) { + if (!this.elements[key]) { + throw new Error(`TRMNLPicker: Required element "${key}" not found in form`) + } + } + } + + /** + * Bind event listeners to form elements + * @private + */ + _bindEvents() { + // Store bound handlers for cleanup + this.handlers = { + modelChange: this._handleModelChange.bind(this), + paletteChange: this._handlePaletteChange.bind(this), + orientationToggle: this._toggleOrientation.bind(this), + darkModeToggle: this._toggleDarkMode.bind(this), + reset: this._resetToDefaults.bind(this) + } + + // Attach event listeners + this.elements.modelSelect.addEventListener('change', this.handlers.modelChange) + this.elements.paletteSelect.addEventListener('change', this.handlers.paletteChange) + + if (this.elements.orientationToggle) { + this.elements.orientationToggle.addEventListener('click', this.handlers.orientationToggle) + } + + if (this.elements.darkModeToggle) { + this.elements.darkModeToggle.addEventListener('click', this.handlers.darkModeToggle) + } + + if (this.elements.resetButton) { + this.elements.resetButton.addEventListener('click', this.handlers.reset) + } + } + + /** + * Set initial state and populate form + * @private + */ + _setInitialState() { + // Populate model select with all models + this.elements.modelSelect.innerHTML = '' + this.models.forEach(model => { + const option = document.createElement('option') + option.value = model.name + option.textContent = model.label || model.name + this.elements.modelSelect.appendChild(option) + }) + + // Set default model (first model or specific default) + const defaultModel = this.models[0] + this.elements.modelSelect.value = defaultModel.name + this.state.selectedModel = defaultModel + + // Populate palettes based on selected model + this._populatePalettes() + + // Set first palette as default + const firstPaletteId = defaultModel.palette_ids[0] + this.elements.paletteSelect.value = firstPaletteId + this.state.selectedPalette = this.palettes.find(p => p.id === firstPaletteId) + + // Update UI + this._updateResetButton() + } + + /** + * Populate palette dropdown based on selected model + * @private + */ + _populatePalettes() { + const modelName = this.elements.modelSelect.value + const model = this.models.find(m => m.name === modelName) + + if (!model) return + + // Clear existing options + this.elements.paletteSelect.innerHTML = '' + + // Add options for each palette_id in the model + model.palette_ids.forEach(paletteId => { + const palette = this.palettes.find(p => p.id === paletteId) + if (palette) { + const option = document.createElement('option') + option.value = palette.id + option.textContent = palette.name + this.elements.paletteSelect.appendChild(option) + } + }) + } + + /** + * Calculate screen classes based on current state + * @private + * @returns {Array} Array of CSS class names + */ + _calculateScreenClasses() { + const model = this.state.selectedModel + const palette = this.state.selectedPalette + + if (!model) { + throw new Error('No model selected') + } + + const classes = [] + + // 1. Palette framework class + if (palette && palette.framework_class) { + classes.push(palette.framework_class) + } + + // 2. Model name + classes.push(`screen--${model.name}`) + + // 3. Model size + if (model.size) { + classes.push(`screen--${model.size}`) + } + + // 4. Orientation + classes.push(`screen--${this.state.isPortrait ? 'portrait' : 'landscape'}`) + + // 5. Scale (always 1x) + classes.push('screen--1x') + + // 6. Dark mode (conditional) + if (this.state.isDarkMode) { + classes.push('screen--dark-mode') + } + + return classes + } + + /** + * Emit 'changed' event with current state and screen classes + * @private + */ + _emitChangeEvent() { + const model = this.state.selectedModel + const palette = this.state.selectedPalette + + const event = new CustomEvent('changed', { + detail: { + screenClasses: this._calculateScreenClasses(), + state: { + model: model ? { + name: model.name, + label: model.label, + size: model.size, + width: model.width, + height: model.height + } : null, + palette: palette ? { + id: palette.id, + name: palette.name, + framework_class: palette.framework_class + } : null, + isPortrait: this.state.isPortrait, + isDarkMode: this.state.isDarkMode + } + }, + bubbles: true + }) + + this.formElement.dispatchEvent(event) + } + + /** + * Handle model selection change + * @private + */ + _handleModelChange(event) { + const modelName = event.target.value + const model = this.models.find(m => m.name === modelName) + + if (!model) return + + // Update state + this.state.selectedModel = model + + // Repopulate palettes for new model + this._populatePalettes() + + // Select first palette of new model + const firstPaletteId = model.palette_ids[0] + this.elements.paletteSelect.value = firstPaletteId + this.state.selectedPalette = this.palettes.find(p => p.id === firstPaletteId) + + // Update UI + this._updateResetButton() + + // Emit change event + this._emitChangeEvent() + } + + /** + * Handle palette selection change + * @private + */ + _handlePaletteChange(event) { + const paletteId = event.target.value + const palette = this.palettes.find(p => p.id === paletteId) + + this.state.selectedPalette = palette + + // Update UI + this._updateResetButton() + + // Emit change event + this._emitChangeEvent() + } + + /** + * Toggle orientation between portrait and landscape + * @private + */ + _toggleOrientation() { + this.state.isPortrait = !this.state.isPortrait + + // Update optional UI elements + if (this.elements.orientationText) { + this.elements.orientationText.textContent = this.state.isPortrait ? 'Portrait' : 'Landscape' + } + + // Emit change event + this._emitChangeEvent() + } + + /** + * Toggle dark mode on/off + * @private + */ + _toggleDarkMode() { + this.state.isDarkMode = !this.state.isDarkMode + + // Update optional UI elements + if (this.elements.darkModeText) { + this.elements.darkModeText.textContent = this.state.isDarkMode ? 'Dark Mode' : 'Light Mode' + } + + // Emit change event + this._emitChangeEvent() + } + + /** + * Reset palette to model's default (first palette) + * @private + */ + _resetToDefaults() { + const model = this.state.selectedModel + if (!model) return + + // Reset to first palette of current model + const firstPaletteId = model.palette_ids[0] + this.elements.paletteSelect.value = firstPaletteId + this.state.selectedPalette = this.palettes.find(p => p.id === firstPaletteId) + + // Update UI + this._updateResetButton() + + // Emit change event + this._emitChangeEvent() + } + + /** + * Update reset button enabled/disabled state + * @private + */ + _updateResetButton() { + if (!this.elements.resetButton) return + + const model = this.state.selectedModel + if (!model) return + + const isAtDefaults = this.elements.paletteSelect.value === String(model.palette_ids[0]) + + this.elements.resetButton.disabled = isAtDefaults + + if (isAtDefaults) { + this.elements.resetButton.classList.add('opacity-50', 'cursor-default') + this.elements.resetButton.setAttribute('aria-disabled', 'true') + } else { + this.elements.resetButton.classList.remove('opacity-50', 'cursor-default') + this.elements.resetButton.removeAttribute('aria-disabled') + } + } + + /** + * Update picker with new configuration + * @public + * @param {Object} config - Configuration object + * @param {string} config.modelName - Model name to select + * @param {string} config.paletteId - Palette ID to select + * @param {boolean} config.isPortrait - Portrait orientation + * @param {boolean} config.isDarkMode - Dark mode enabled + */ + update(config) { + if (!config || typeof config !== 'object') { + throw new Error('TRMNLPicker.update: config must be an object') + } + + let changed = false + + // Update model if provided + if (config.modelName) { + const model = this.models.find(m => m.name === config.modelName) + if (model) { + this.elements.modelSelect.value = model.name + this.state.selectedModel = model + this._populatePalettes() + changed = true + } + } + + // Update palette if provided + if (config.paletteId) { + const palette = this.palettes.find(p => p.id === config.paletteId) + if (palette) { + this.elements.paletteSelect.value = palette.id + this.state.selectedPalette = palette + changed = true + } + } + + // Update orientation if provided + if (typeof config.isPortrait === 'boolean') { + this.state.isPortrait = config.isPortrait + if (this.elements.orientationText) { + this.elements.orientationText.textContent = this.state.isPortrait ? 'Portrait' : 'Landscape' + } + changed = true + } + + // Update dark mode if provided + if (typeof config.isDarkMode === 'boolean') { + this.state.isDarkMode = config.isDarkMode + if (this.elements.darkModeText) { + this.elements.darkModeText.textContent = this.state.isDarkMode ? 'Dark Mode' : 'Light Mode' + } + changed = true + } + + if (changed) { + this._updateResetButton() + this._emitChangeEvent() + } + } + + /** + * Get current picker state + * @public + * @returns {Object} Current state including model, palette, flags, and screen classes + */ + getState() { + return { + model: this.state.selectedModel, + palette: this.state.selectedPalette, + isPortrait: this.state.isPortrait, + isDarkMode: this.state.isDarkMode, + screenClasses: this._calculateScreenClasses() + } + } + + /** + * Clean up event listeners and references + * @public + */ + destroy() { + // Remove event listeners + this.elements.modelSelect.removeEventListener('change', this.handlers.modelChange) + this.elements.paletteSelect.removeEventListener('change', this.handlers.paletteChange) + + if (this.elements.orientationToggle) { + this.elements.orientationToggle.removeEventListener('click', this.handlers.orientationToggle) + } + + if (this.elements.darkModeToggle) { + this.elements.darkModeToggle.removeEventListener('click', this.handlers.darkModeToggle) + } + + if (this.elements.resetButton) { + this.elements.resetButton.removeEventListener('click', this.handlers.reset) + } + + // Clear references + this.formElement = null + this.elements = null + this.handlers = null + this.models = null + this.palettes = null + this.state = null + } +} + +export default TRMNLPicker