Initial working demo

This commit is contained in:
Rockwell Schrock
2025-12-12 09:30:42 -05:00
commit b470fc9aa5
7 changed files with 1740 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
*.log
.DS_Store
.vscode/
.idea/
+8
View File
@@ -0,0 +1,8 @@
example/
src/
node_modules/
.gitignore
*.log
.DS_Store
.vscode/
.idea/
+439
View File
@@ -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
<script src="https://unpkg.com/@trmnl/picker@latest/dist/trmnl-picker.min.js"></script>
```
### Browser (Local)
```html
<script src="dist/trmnl-picker.min.js"></script>
```
## Quick Start
### 1. Create HTML Structure
The library expects a form with specific element IDs:
```html
<form id="picker-form">
<!-- Required: Model selector -->
<select id="model-select"></select>
<!-- Required: Palette selector -->
<select id="palette-select"></select>
<!-- Optional: Orientation toggle -->
<button type="button" id="orientation-toggle">
<span data-orientation-text>Landscape</span>
</button>
<!-- Optional: Dark mode toggle -->
<button type="button" id="dark-mode-toggle">
<span data-dark-mode-text>Light Mode</span>
</button>
<!-- Optional: Reset button -->
<button type="button" id="reset-button">Reset</button>
</form>
```
### 2. Initialize Picker
#### Browser Usage
```html
<script src="dist/trmnl-picker.min.js"></script>
<script>
// Get data from your API or define locally
const models = [
{
name: 'trmnl_original',
label: 'TRMNL Original',
size: '2.9',
width: 800,
height: 480,
palette_ids: ['bw', '4c', '7c']
}
]
const palettes = [
{ id: 'bw', name: 'Black & White', framework_class: 'palette-bw' },
{ id: '4c', name: '4-Color', framework_class: 'palette-4c' },
{ id: '7c', name: '7-Color', framework_class: 'palette-7c' }
]
// Initialize (TRMNLPicker is available globally)
const picker = new TRMNLPicker('picker-form', models, palettes)
</script>
```
#### 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 (
<form id="picker-form">
<select id="model-select"></select>
<select id="palette-select"></select>
<button type="button" id="orientation-toggle">
<span data-orientation-text>Landscape</span>
</button>
<button type="button" id="dark-mode-toggle">
<span data-dark-mode-text>Light Mode</span>
</button>
<button type="button" id="reset-button">Reset</button>
</form>
)
}
```
## 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
<div data-controller="screen-picker"
data-screen-picker-models-value="<%= models.to_json %>"
data-screen-picker-palettes-value="<%= palettes.to_json %>">
<select data-screen-picker-target="modelSelect"></select>
</div>
```
**After (Vanilla):**
```html
<form id="picker-form">
<select id="model-select"></select>
</form>
<script type="module">
import TRMNLPicker from '@trmnl/picker'
const picker = new TRMNLPicker('picker-form', models, palettes)
</script>
```
## 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).
+337
View File
@@ -0,0 +1,337 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TRMNL Picker - Browser Example</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
margin: 0;
padding: 2rem;
background: #f5f5f5;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 0.5rem;
}
p {
color: #666;
margin-bottom: 2rem;
}
.picker-wrapper {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
}
.picker-form {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.picker-select,
.picker-button {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
border-radius: 6px;
background: white;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
}
.picker-select:hover,
.picker-button:hover:not(:disabled) {
background: #f9f9f9;
border-color: #ccc;
}
.picker-select:focus,
.picker-button:focus {
outline: none;
border-color: #4a90e2;
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1);
}
.picker-button:disabled {
opacity: 0.5;
cursor: not-allowed;
background: #f5f5f5;
}
.divider {
width: 1px;
height: 2rem;
background: #e0e0e0;
margin: 0 0.25rem;
}
.output-section {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
}
.output-section h2 {
margin: 0 0 1rem 0;
color: #333;
font-size: 1.25rem;
}
.output-content {
background: #f8f9fa;
padding: 1rem;
border-radius: 4px;
border: 1px solid #e0e0e0;
}
pre {
margin: 0;
overflow-x: auto;
font-size: 0.875rem;
line-height: 1.5;
}
.screen-preview {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.screen-preview h2 {
margin: 0 0 1.5rem 0;
color: #333;
font-size: 1.25rem;
}
.screen {
display: inline-block;
border: 2px solid #333;
background: white;
position: relative;
transition: all 0.3s ease;
}
.screen--trmnl_original {
width: 400px;
height: 240px;
}
.screen--og_plus {
width: 296px;
height: 128px;
}
.screen--pro {
width: 800px;
height: 480px;
max-width: 100%;
}
.screen--portrait {
transform: rotate(90deg);
transform-origin: center;
margin: 100px 0;
}
.screen--dark-mode {
background: #1a1a1a;
border-color: #666;
}
.screen-content {
padding: 1rem;
text-align: center;
color: #666;
}
.screen--dark-mode .screen-content {
color: #ccc;
}
.palette-bw { background: white; color: black; }
.palette-4c { background: linear-gradient(to right, #fff, #ff0, #f00, #000); }
.palette-7c { background: linear-gradient(to right, #fff, #ff0, #f80, #f00, #00f, #0f0, #000); }
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
background: #4a90e2;
color: white;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
margin-left: 0.5rem;
}
</style>
</head>
<body>
<div class="container">
<h1>@trmnl/picker - Browser Example</h1>
<p>Vanilla JavaScript library for TRMNL device model and palette selection</p>
<div class="picker-wrapper">
<form id="picker-form" class="picker-form">
<select id="model-select" class="picker-select"></select>
<select id="palette-select" class="picker-select"></select>
<div class="divider"></div>
<button type="button" id="orientation-toggle" class="picker-button">
<span data-orientation-text>Landscape</span>
</button>
<button type="button" id="dark-mode-toggle" class="picker-button">
<span data-dark-mode-text>Light Mode</span>
</button>
<div class="divider"></div>
<button type="button" id="reset-button" class="picker-button">
Reset
</button>
</form>
</div>
<div class="output-section">
<h2>Event Output <span class="badge" id="event-count">0 events</span></h2>
<div class="output-content">
<pre id="event-output">Waiting for changes...</pre>
</div>
</div>
<div class="screen-preview">
<h2>Screen Preview</h2>
<div class="screen">
<div class="screen-content">
<strong>TRMNL Screen Preview</strong>
</div>
</div>
</div>
</div>
<script src="../dist/trmnl-picker.min.js"></script>
<script>
// Sample data (simulating API responses)
const models = [
{
name: 'trmnl_original',
label: 'TRMNL Original',
description: 'Original TRMNL model',
width: 800,
height: 480,
colors: 2,
bit_depth: 1,
scale_factor: 1.0,
rotation: 90,
mime_type: 'image/png',
offset_x: 10,
offset_y: 20,
published_at: '2023-10-01T12:00:00Z',
kind: 'trmnl',
size: '2.9',
palette_ids: ['bw', '4c', '7c']
},
{
name: 'og_plus',
label: 'TRMNL OG+',
description: 'OG Plus model',
width: 296,
height: 128,
size: '2.9',
palette_ids: ['bw', '4c']
},
{
name: 'pro',
label: 'TRMNL Pro',
description: 'Pro model with full color',
width: 800,
height: 480,
size: '7.3',
palette_ids: ['7c', 'bw']
}
]
const palettes = [
{ id: 'bw', name: 'Black & White', framework_class: 'palette-bw' },
{ id: '4c', name: '4-Color', framework_class: 'palette-4c' },
{ id: '7c', name: '7-Color', framework_class: 'palette-7c' }
]
// Initialize picker
const picker = new TRMNLPicker('picker-form', models, palettes)
// Track events
let eventCount = 0
// Listen for changes
document.getElementById('picker-form').addEventListener('changed', (event) => {
eventCount++
document.getElementById('event-count').textContent = `${eventCount} event${eventCount !== 1 ? 's' : ''}`
const { screenClasses, state } = event.detail
// Display event data
document.getElementById('event-output').textContent = JSON.stringify({
screenClasses,
state
}, null, 2)
// Apply classes to screen preview
const screen = document.querySelector('.screen')
const currentClasses = screen.className.split(' ')
// Remove old screen classes
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(' ')
console.log('Picker changed:', event.detail)
})
// Example: Programmatic update after 3 seconds
setTimeout(() => {
console.log('Programmatically updating picker...')
picker.update({
modelName: 'pro',
paletteId: '7c',
isPortrait: false,
isDarkMode: false
})
}, 3000)
// Display current state in console
console.log('Initial state:', picker.getState())
</script>
</body>
</html>
+446
View File
@@ -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"
}
}
}
}
+39
View File
@@ -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"
}
}
+465
View File
@@ -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<Object>} models - Array of model objects from API
* @param {Array<Object>} 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<string>} 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