├── mod.ts ├── fonts ├── Figtree │ ├── Figtree-Bold.woff2 │ ├── Figtree-Italic.woff2 │ └── Figtree-Regular.woff2 ├── FiraCode │ ├── FiraCode-Bold.woff2 │ └── FiraCode-Regular.woff2 └── LibreCaslonText │ ├── LibreCaslonText-Bold.woff2 │ ├── LibreCaslonText-Italic.woff2 │ └── LibreCaslonText-Regular.woff2 ├── .gitignore ├── src ├── md.ts └── keynav.ts ├── deno.json ├── CHANGELOG.md ├── LICENSE └── README.md /mod.ts: -------------------------------------------------------------------------------- 1 | export { default as md } from './src/md.ts'; 2 | export { default as keynav } from './src/keynav.ts'; 3 | -------------------------------------------------------------------------------- /fonts/Figtree/Figtree-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarcajadaArtificial/lunchbox/HEAD/fonts/Figtree/Figtree-Bold.woff2 -------------------------------------------------------------------------------- /fonts/Figtree/Figtree-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarcajadaArtificial/lunchbox/HEAD/fonts/Figtree/Figtree-Italic.woff2 -------------------------------------------------------------------------------- /fonts/FiraCode/FiraCode-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarcajadaArtificial/lunchbox/HEAD/fonts/FiraCode/FiraCode-Bold.woff2 -------------------------------------------------------------------------------- /fonts/Figtree/Figtree-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarcajadaArtificial/lunchbox/HEAD/fonts/Figtree/Figtree-Regular.woff2 -------------------------------------------------------------------------------- /fonts/FiraCode/FiraCode-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarcajadaArtificial/lunchbox/HEAD/fonts/FiraCode/FiraCode-Regular.woff2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.css.map 2 | deno.lock 3 | .DS_Store 4 | .obsidian 5 | .vscode 6 | .cursor/ 7 | _fresh/ 8 | docs/ 9 | node_modules/ 10 | -------------------------------------------------------------------------------- /fonts/LibreCaslonText/LibreCaslonText-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarcajadaArtificial/lunchbox/HEAD/fonts/LibreCaslonText/LibreCaslonText-Bold.woff2 -------------------------------------------------------------------------------- /fonts/LibreCaslonText/LibreCaslonText-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarcajadaArtificial/lunchbox/HEAD/fonts/LibreCaslonText/LibreCaslonText-Italic.woff2 -------------------------------------------------------------------------------- /fonts/LibreCaslonText/LibreCaslonText-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarcajadaArtificial/lunchbox/HEAD/fonts/LibreCaslonText/LibreCaslonText-Regular.woff2 -------------------------------------------------------------------------------- /src/md.ts: -------------------------------------------------------------------------------- 1 | import { render, type RenderOptions } from '@deno/gfm'; 2 | 3 | interface MarkdownProps { 4 | content: string; 5 | renderOptions?: RenderOptions; 6 | transform?: (content: string) => string; 7 | } 8 | 9 | export default function ( 10 | props: MarkdownProps, 11 | ): { dangerouslySetInnerHTML: { __html: string } } { 12 | const content = render(props.content, props.renderOptions); 13 | 14 | return { 15 | dangerouslySetInnerHTML: { 16 | __html: props.transform ? props.transform(content) : content, 17 | }, 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lunchbox/ui", 3 | "version": "3.0.1", 4 | "license": "MIT", 5 | "exports": { 6 | ".": "./mod.ts" 7 | }, 8 | "tasks": { 9 | "init:clean": "deno run -A tasks.ts init-clean", 10 | "init:map": "deno run -A tasks.ts init-map" 11 | }, 12 | "compilerOptions": { 13 | "jsx": "react-jsx", 14 | "jsxImportSource": "preact" 15 | }, 16 | "fmt": { "exclude": [".github/dep", "*.md"], "singleQuote": true }, 17 | "imports": { 18 | "@deno/gfm": "jsr:@deno/gfm@^0.11.0", 19 | "preact": "npm:preact@^10.26.6", 20 | "zod": "npm:zod@^3.25.36" 21 | }, 22 | "nodeModulesDir": "auto", 23 | "lock": false 24 | } 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Backlog 4 | 5 | ### New Features 6 | 7 | - An island for using the IntersectionObserver API. 8 | - Extend the `.layout` class for layouts inside layouts. 9 | - Keynav that keeps the scroll position consitent. 10 | - KeynavTutorial island that displays a `` element, a label and listens 11 | for the first key press of that keys and visually marks the keys as completed. 12 | 13 | ### JSR 14 | 15 | - Module documentation. 16 | - GitHub actions for publish. 17 | 18 | ## Version History 19 | 20 | ### 3.0.1 21 | 22 | - Added an update to the `keynav` utility function where it outputs the effect 23 | function and not the whole island. 24 | 25 | ### 3.0.0 26 | 27 | - Now built on top of DaisyUI and TailwindCSS. 28 | - Added the DaisyUI compatible themes Lunchbox and Supperbox (nickname for the 29 | dark mode theme). 30 | - Added noise style utilities. 31 | - Removed components redundant to DaisyUI. 32 | - Added the `md` utility function replacing the `` component. 33 | - Added the `` island. 34 | - Removed the CSS module in favor of `npm:lunchbox-css`. 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Oscar Alfonso Guerrero 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/keynav.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'preact/hooks'; 2 | 3 | const DIRECTIONS = [ 4 | 'ArrowUp', 5 | 'ArrowDown', 6 | 'ArrowLeft', 7 | 'ArrowRight', 8 | ]; 9 | type Direction = typeof DIRECTIONS[number]; 10 | 11 | const SHAKE_CLASSES = [ 12 | 'shake_up', 13 | 'shake_down', 14 | 'shake_left', 15 | 'shake_right', 16 | 'shake', 17 | ] as const; 18 | type ShakeClass = typeof SHAKE_CLASSES[number]; 19 | 20 | function overlaps(aMin: number, aMax: number, bMin: number, bMax: number) { 21 | return !(aMax < bMin || aMin > bMax); 22 | } 23 | 24 | function findCandidate( 25 | current: HTMLElement, 26 | direction: Direction, 27 | candidates: HTMLElement[], 28 | padding: number = 0, 29 | ): HTMLElement | null { 30 | const currentRect = current.getBoundingClientRect(); 31 | const paddedY = { 32 | min: currentRect.top - padding, 33 | max: currentRect.bottom + padding, 34 | }; 35 | const paddedX = { 36 | min: currentRect.left - padding, 37 | max: currentRect.right + padding, 38 | }; 39 | 40 | type Entry = { el: HTMLElement; distance: number }; 41 | const entries: Entry[] = []; 42 | 43 | for (const el of candidates) { 44 | if (el === current) continue; 45 | const rect = el.getBoundingClientRect(); 46 | let distance = Infinity; 47 | let ok = false; 48 | 49 | switch (direction) { 50 | case 'ArrowRight': 51 | ok = rect.left > currentRect.right && 52 | overlaps(rect.top, rect.bottom, paddedY.min, paddedY.max); 53 | if (ok) distance = rect.left - currentRect.right; 54 | break; 55 | case 'ArrowLeft': 56 | ok = rect.right < currentRect.left && 57 | overlaps(rect.top, rect.bottom, paddedY.min, paddedY.max); 58 | if (ok) distance = currentRect.left - rect.right; 59 | break; 60 | case 'ArrowDown': 61 | ok = rect.top > currentRect.bottom && 62 | overlaps(rect.left, rect.right, paddedX.min, paddedX.max); 63 | if (ok) distance = rect.top - currentRect.bottom; 64 | break; 65 | case 'ArrowUp': 66 | ok = rect.bottom < currentRect.top && 67 | overlaps(rect.left, rect.right, paddedX.min, paddedX.max); 68 | if (ok) distance = currentRect.top - rect.bottom; 69 | break; 70 | } 71 | 72 | if (ok) entries.push({ el, distance }); 73 | } 74 | 75 | if (entries.length === 0) return null; 76 | 77 | // pick the entry with the smallest distance 78 | let best = entries[0]; 79 | for (const e of entries) { 80 | if (e.distance < best.distance) best = e; 81 | } 82 | 83 | return best.el; 84 | } 85 | 86 | function resetShake(el: HTMLElement, exclude?: ShakeClass) { 87 | SHAKE_CLASSES.forEach((cls) => { 88 | if (cls !== exclude && el.classList.contains(cls)) { 89 | el.classList.remove(cls); 90 | } 91 | }); 92 | } 93 | 94 | function handleKeyDown(this: HTMLElement, e: KeyboardEvent) { 95 | const { key } = e; 96 | 97 | if (key === 'Enter') { 98 | resetShake(this); 99 | void this.offsetWidth; 100 | this.classList.add('shake'); 101 | return; 102 | } 103 | 104 | if (key === 'Esc') this.blur(); 105 | 106 | if (!DIRECTIONS.includes(key)) return; 107 | 108 | e.preventDefault(); 109 | resetShake(this); 110 | 111 | const tabbedElements = Array.from( 112 | document.querySelectorAll('[tabindex="0"]'), 113 | ); 114 | const candidate = findCandidate(this, key, tabbedElements, 100); 115 | 116 | if (candidate) { 117 | this.removeEventListener('keydown', handleKeyDown); 118 | candidate.focus(); 119 | return; 120 | } 121 | 122 | const dir = key.replace('Arrow', '').toLowerCase(); 123 | const shakeClass = `shake_${dir}` as ShakeClass; 124 | 125 | this.classList.add(shakeClass); 126 | } 127 | 128 | /** 129 | * Attach the `handleKeyDown()` listener when an element is focused. 130 | */ 131 | function handleFocusIn(e: FocusEvent) { 132 | const t = e.target; 133 | if (t instanceof HTMLElement && t.tabIndex === 0) { 134 | t.addEventListener('keydown', handleKeyDown); 135 | } 136 | } 137 | 138 | /** 139 | * Remove the `handleKeyDown()` listener when an element is focused. 140 | */ 141 | function handleFocusOut(e: FocusEvent) { 142 | const t = e.target; 143 | if (t instanceof HTMLElement && t.tabIndex === 0) { 144 | t.removeEventListener('keydown', handleKeyDown); 145 | resetShake(t); 146 | } 147 | } 148 | 149 | export default function () { 150 | document.addEventListener('focusin', handleFocusIn); 151 | document.addEventListener('focusout', handleFocusOut); 152 | } 153 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🍱 Lunchbox 2 | 3 | [![JSR](https://jsr.io/badges/@lunchbox/ui)](https://jsr.io/@lunchbox/ui) 4 | [![JSR](https://jsr.io/badges/@lunchbox/ui/score)](https://jsr.io/@lunchbox/ui) 5 | 6 | `` Hello ( ´ ω ` )ノ゙ `` Welcome to 🍱 Lunchbox. A lightweight, server-first 7 | styling layer built on top of 💨 **TailwindCSS v4** and 🌼 **DaisyUI v5**, 8 | tailor-made for 🦕 **Deno** 🍋 **Fresh v2** and **Preact**. 9 | 10 | ## Features 11 | 12 | - 🎯 **Custom Design Tokens**: Includes extended spacing scale and responsive 13 | breakpoints tailored for fine-tuned layouts. 14 | - ✍️ **GFM-Optimized Typography**: Integrates the Tailwind Typography plugin 15 | with custom styles specifically tuned for GitHub-Flavored Markdown. 16 | - 🎨 **DaisyUI Theming**: Built on top of DaisyUI with both light and dark 17 | themes ready to go. 18 | - 🧱 **Responsive Grid Layout**: Includes a flexible, column-based layout system 19 | for responsive content arrangement. 20 | - ⌨️ **Keyboard Navigation Island**: Includes an interactive component for 21 | arrow-key-based focus navigation between elements. 22 | 23 | ## Installation 24 | 25 | Before starting add the following configuration to `./deno.json`: 26 | 27 | ```json 28 | { 29 | "nodeModulesDir": "auto" 30 | } 31 | ``` 32 | 33 | ### 1. Install 🍋 Fresh 34 | 35 | There are three approaches to installing Fresh: v2 init, v1 init, and manual 36 | setup. All of these have their own benefits so chose any you like as long as you 37 | don't include Tailwind in your installation, if you do you will have to update 38 | it to v4. It is definetly recommended at least a basic understanding of how 39 | Fresh works as well. 40 | 41 | Change `PROJECT_NAME` for the name of the root directory for the project. 42 | 43 | ```sh 44 | deno run -Ar jsr:@fresh/init@2.0.0-alpha.34 PROJECT_NAME --tailwind=false 45 | ``` 46 | 47 | ### 2. Install 💨 TailwindCSS 48 | 49 | This library is built on top of the latest version of TailwindCSS (v4) and it's 50 | incompatible for any previous one. It is because of this that TailwindCSS must 51 | not be installed along with the Fresh boilerplate project. 52 | 53 | 1. Install TailwindCSS core v4. 54 | 55 | ```sh 56 | deno add npm:tailwindcss 57 | ``` 58 | 59 | 2. Install the Typography plugin for TailwindCSS. 60 | 61 | ```sh 62 | deno add npm:@tailwindcss/typography@^0.5.16 63 | ``` 64 | 65 | 3. Install [@pakornv's](https://github.com/pakornv) Fresh Plugin TailwindCSS for 66 | v4. Version 2.0 is still in alpha so try to keep up with this project's 67 | updates. As a note, there is an official Fresh Plugin TailwindCSS, but 68 | currently only supports v3. 69 | 70 | ```sh 71 | deno add --allow-scripts jsr:@pakornv/fresh-plugin-tailwindcss@2.0.0-alpha.1 72 | ``` 73 | 74 | 4. Enable the plugin in your application. 75 | 76 | ```ts 77 | // dev.ts 78 | 79 | import { tailwind } from "@fresh/plugin-tailwind"; 80 | 81 | tailwind(devApp); 82 | ``` 83 | 84 | 5. Add the styles module. 85 | 86 | ```css 87 | /* ./static/styles.css */ 88 | 89 | /* Add these: */ 90 | @import "tailwindcss"; 91 | @plugin "@tailwindcss/typography"; 92 | ``` 93 | 94 | ### 3. Install 🌼 DaisyUI 95 | 96 | On top of TailwindCSS, DaisyUI adds a layer of purce SSR components made out of 97 | pure HTML and CSS and a powerful theme system. 98 | 99 | ```sh 100 | deno add npm:daisyui 101 | ``` 102 | 103 | ```css 104 | /* ./static/styles.css */ 105 | 106 | @import "tailwindcss"; 107 | 108 | @plugin "@tailwindcss/typography"; 109 | 110 | /* Add this: */ 111 | @plugin "daisyui" { 112 | /* 113 | Themes are disabled because they will be replaced by Lunchbox's 114 | custom themes. 115 | */ 116 | themes: false; 117 | } 118 | ``` 119 | 120 | ### 4. Install 🍱 Lunchbox 121 | 122 | Now that everything is set up, you can add this library. There are two parts of 123 | this, one with Preact components and TypeScript utilities and another with a the 124 | CSS modules. 125 | 126 | ```sh 127 | deno add jsr:@lunchbox/ui npm:lunchbox-css 128 | ``` 129 | 130 | ```css 131 | /* ./static/styles.css */ 132 | 133 | @import "tailwindcss"; 134 | /* Add this: */ 135 | @import "../node_modules/lunchbox-css/index.css"; 136 | 137 | @plugin "@tailwindcss/typography"; 138 | 139 | @plugin "daisyui" { 140 | themes: false; 141 | } 142 | ``` 143 | 144 | ```ts 145 | import /* Components and utilities. */ "@lunchbox/ui"; 146 | ``` 147 | 148 | ## Usage 149 | 150 | There are a few layers of the interface where Lunchbox interacts, design tokens, 151 | server components, and interactive islands. 152 | 153 | > [!Warning] Here is where the actual opinions of this library appear. I've seen 154 | > many packages speak about how "opinionated" they are. So here's a word of 155 | > warning, these contain _opinionated opinion that you might not agree with (and 156 | > that's okay)_. 157 | 158 | ### Design Tokens 159 | 160 | - **Breakpoints**: The way the custom breakpoints are thought as "window" 161 | breakpoint and not "device" breakpoints. Lunchbox replaces 162 | [Tailwind's Responsive Design](https://tailwindcss.com/docs/responsive-design#overview) 163 | with two simple breakpoints: `md` with a value of `40em` (equivalent to 164 | Tailwind's `sm`), and `lg` with a value of `80em` (equivalent to Tailwind's 165 | `xl`). 166 | 167 | The _opinion_ here is that having five breakpoints create too many viewport 168 | width ranges that create UI variations for a ver small percentage of window 169 | instances. This implemantation also follows the "mobile first" philosophy by 170 | having no breakpoint for "small" devices by it being the default. 171 | 172 | ```html 173 | 174 |
175 | 176 |