alpine-components-tombras
v1.0.4
Published
Agnostic, reusable scripts that handle the behavior of the UI. When needed, data is provided to a component through a service.
Readme
Shared Front-End Components: Build & Maintenance Guide
1. Key Concepts
Components
Agnostic, reusable scripts that handle the behavior of the UI. When needed, data is provided to a component through a service.
Services
Brand specific, implementation oriented scripts that provide data to the DOM.
Principles
- Separation of Concerns: Components remain agnostic, services handle implementation.
- Event-Driven Communication: Components and services interact via custom events.
- Zero Dependencies: Components should work standalone (except Alpine.js/Tailwind)
Component Structure
Every component should have its name as a folder inside the scripts folder, with the actual js file as index.js.
File Organization
frontend/
├── scripts/
│ ├── ExampleComponent/
│ │ ├── index.js
│ │ └── README.md
│ └── AnotherComponent/
│ ├── index.js
│ └── README.mdComponent Template
// @param {string} id unique identifier for the component. Mostly used when multiple instances of a component are listening to similar events. Must be equal to service's id it's listening to.
// @param {string} initializationEvent the event the component might need to listen for a certain action or initialization.
// @param {object} props the component specific properties that might be needed.
export function ExampleComponent(
props = { id: "", initializationEvent: "", prop1, prop2, prop3 }
) {
return {
// Component state.
isActive: false,
// Initialization
init() {
// If id is declared, the component will only listen for the unique event, otherwise it will listen to the generic one. If no event is passed through, initialize normally.
const { id, initializationEvent } = props;
if (initializationEvent) {
const uniqueInitializationEvent = id
? initializationEvent + "-" + id
: initializationEvent;
window.addEventListener(uniqueInitializationEvent, () => {
this.initExampleComponent();
});
} else {
this.initExampleComponent();
}
},
initExampleComponent() {
// Your initialization logic...
},
// Methods
toggle() {
this.isActive = !this.isActive;
},
// Cleanup
destroy() {
// Remove event listeners.
},
};
}Service Template
Services should handle API requests, loading states and API error handling.
// @param {string} id unique identifier for the service. Will transform a generic event to a unique one. The components listening for this service's events must be assigned the same id.
// @param {string} eventName the name of the event to dispatch, if an id is provided, the event will dispatch as unique, with the format eventName + '-' + id
function ExampleService(id = "", eventName = "") {
return {
// Service state
isLoading: false,
error: null,
data: null,
// Initialization
async init() {
// Your initialization logic
await this.loadData();
},
async loadData() {
try {
this.data = await this.fetchApi();
this.$nextTick(() => {
const uniqueEventName = id ? eventName + "-" + id : eventName;
window.dispatchEvent(
new CustomEvent(uniqueEventName, {
detail: {
data: this.data,
},
})
);
});
} catch (error) {
console.error("Error loading data:", error);
this.error = error.message;
this.data = null;
}
},
// Api fetching logic.
async fetchApi() {
this.isLoading = true;
this.error = null;
try {
const response = await fetch();
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
return json;
} catch (error) {
console.error("Error fetching data:", error);
throw error;
} finally {
this.isLoading = false;
}
},
// Utility logic.
getCurrency(number) {
return number.toLocaleString();
},
};
}2. Component Documentation
Each component must include a README.md with the following structure:
Template
# Component Name
## Description
Brief description of what the component does.
## Requirements
Necessary requirements for the component to work.
## Example usage
```html
<div x-data="componentName.data()">
<!-- Component HTML structure -->
</div>
```
```
## Properties
- `propertyName` (type): Description
## State
- `stateName` (type): Description
## Methods
- `methodName()`: Description
## Notes
```3. Distribution Logic
Npm registry
We publish one version (ESM) to be used in modules like follows (Keep in mind this would need to be rebuilt for usage.):
import { Select } from "alpine-components-tombras";
export function data(props = {}) {
return {
...Select(props),
};
}We publish another version for browser compatibility, which we can then include directly in our HTML. (CDN approach might be more suitable.)
<!DOCTYPE html>
<html>
<head>
<script
defer
src="https://unpkg.com/[email protected]/dist/cdn.min.js"
></script>
<script src="<node-modules>/dist/alpine-components-tombras.js"></script>
</head>
<body>
<div
x-data="TombrasComponents.Select({ options: [{value: '1', label: 'Opt 1'},{value:'1', label:'Opt 1'}] })"
>
<p>Your select component should work here</p>
</div>
</body>
</html>Private registry
- Paid
- Must create an organization in npm
- Will be linked to our repository
Public registry
- Free
- Everybody can use it and fork it
4. Implementation
Direct usage
Create your html file, include the installed .js file in a script tag(node-modules/alpine-components-tombras/dist/alpine-components-tombras.js). You can freely use your components registered under the TombrasComponents namespace. Example:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script
defer
src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"
></script>
<script src="../../node_modules/alpine-components-tombras/dist/alpine-components-tombras.js"></script>
</head>
<body>
<!-- Your component example -->
<main class="py-20 flex items-center flex-col justify-center gap-10">
<h1 class="font-bold text-2xl">TabsComponent</h1>
<!-- Tab Component -->
<div
x-data="TombrasComponents.Tabs()"
class="bg-white rounded-lg shadow-lg overflow-hidden"
>
<!-- Tab List -->
<div class="flex border-b border-gray-200" role="tablist">
<button
role="tab"
@click="activateTab(0)"
@keydown="handleKeydown($event)"
:class="activeTab === 0 ? 'bg-blue-500 text-white' : 'bg-gray-50 text-gray-700 hover:bg-gray-100'"
class="flex-1 cursor-pointer px-6 py-3 font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Dashboard
</button>
<button
role="tab"
@click="activateTab(1)"
@keydown="handleKeydown($event)"
:class="activeTab === 1 ? 'bg-blue-500 text-white' : 'bg-gray-50 text-gray-700 hover:bg-gray-100'"
class="flex-1 cursor-pointer px-6 py-3 font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Profile
</button>
<button
role="tab"
@click="activateTab(2)"
@keydown="handleKeydown($event)"
:class="activeTab === 2 ? 'bg-blue-500 text-white' : 'bg-gray-50 text-gray-700 hover:bg-gray-100'"
class="flex-1 cursor-pointer px-6 py-3 font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Settings
</button>
</div>
<!-- Tab Panels -->
<div role="tabpanel" class="p-6">
<h2 class="text-xl font-semibold mb-3 text-gray-800">Dashboard</h2>
<p class="text-gray-600 mb-4">
Welcome to your dashboard! Here you can see an overview of your
account activity.
</p>
<div class="bg-blue-50 p-4 rounded-lg">
<p class="text-blue-800">
📊 Your stats: 42 projects, 128 tasks completed
</p>
</div>
</div>
<div role="tabpanel" class="p-6">
<h2 class="text-xl font-semibold mb-3 text-gray-800">Profile</h2>
<p class="text-gray-600 mb-4">
Manage your personal information and preferences.
</p>
<div class="bg-green-50 p-4 rounded-lg">
<p class="text-green-800">👤 Profile completion: 85%</p>
</div>
</div>
<div role="tabpanel" class="p-6">
<h2 class="text-xl font-semibold mb-3 text-gray-800">Settings</h2>
<p class="text-gray-600 mb-4">
Configure your application settings and preferences.
</p>
<div class="bg-purple-50 p-4 rounded-lg">
<p class="text-purple-800">⚙️ 12 settings available to customize</p>
</div>
</div>
</div>
</main>
</body>
</html>Component extension
If you want to extend the component's functionality, you need to create a new javascript file, import the component/s you'd like to inherit and add/overwrite your desired props. Then you need to build them using esbuild and include them using the load-script tag, doc reference: https://tombras.atlassian.net/wiki/spaces/DEV/pages/2929000450/.NET+Frontend+Development+Process
Example:
import { Select } from "alpine-components-tombras";
export function data(props = {}) {
return {
...Select(props),
};
}- Run build process with namespace selectDropdown
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script
defer
src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"
></script>
</head>
<body>
<main class="py-20 flex flex-col items-center justify-center gap-10">
<h1 class="font-bold text-2xl">SelectDropdown</h1>
<div
x-data="selectDropdown.data({initialValue: 'default', options: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: 'option3', label: 'Option 3' }
]})"
class="relative"
>
<!-- Hidden native select for form submission -->
<select name="dropdown" class="sr-only hidden" x-model="selectedValue">
<template x-for="option in options" :key="option.value">
<option :value="option.value" x-text="option.label"></option>
</template>
</select>
<!-- Custom dropdown trigger -->
<button
@keydown.enter.prevent="toggleOpen()"
@keydown.space.prevent="toggleOpen()"
@keydown.arrow-down.prevent="navigateOptions(1)"
@click="toggleOpen()"
:aria-expanded="open"
class="w-full gap-5 p-4 border-2 border-gray-200 rounded-xl bg-white hover:bg-gray-50 hover:border-blue-300 focus:outline-none focus:ring-4 focus:ring-blue-100 focus:border-blue-400 flex items-center cursor-pointer justify-between transition-all duration-200 shadow-sm"
>
<span x-text="selectedValue" class="text-gray-700 font-medium"></span>
<svg
class="w-5 h-5 transition-transform duration-200 text-gray-400"
:class="{'rotate-180': open}"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
<!-- Dropdown options -->
<div
x-show="open"
x-transition:enter="transition ease-out duration-200"
@click.away="open = false"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
x-cloak
@click.away="open = false"
class="absolute z-10 w-full mt-2 bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden"
role="listbox"
>
<template x-for="(option, index) in options" :key="index">
<button
@click="selectOption(option.value)"
@keydown.enter.prevent="selectOption(option.value)"
@keydown.space.prevent="selectOption(option.value)"
@keydown.arrow-down.prevent="navigateOptions(1)"
@keydown.arrow-up.prevent="navigateOptions(-1)"
:class="{
'bg-blue-50 border-blue-200': highlightedIndex === index,
'bg-blue-500 text-white': selectedValue === option.value
}"
:aria-selected="selectedValue === option.value"
role="option"
:tabindex="highlightedIndex === index ? 0 : -1"
class="w-full cursor-pointer px-4 py-3 text-left hover:bg-gray-50 focus:bg-blue-50 focus:outline-none transition-colors duration-150 border-b border-gray-100 last:border-b-0"
>
<span
x-text="option.label"
class="font-medium text-gray-700"
></span>
</button>
</template>
</div>
</div>
</main>
<script src="./dist/select.js"></script>
</body>
</html>