12 KiB
Creating Custom UI Components for Settings
When adding new setting types to BetterSEQTA+, you'll often need to create custom UI components to render and edit these settings. This guide covers how to create Svelte components for the settings UI and how to integrate them with the settings system.
Understanding the Settings UI
Settings in BetterSEQTA+ are rendered by the src/interface/pages/settings/general.svelte component. This component:
- Loads settings from all plugins
- Maps setting types to appropriate UI components
- Renders the settings UI
- Handles updates when settings are changed
Basic Component Requirements
Every setting UI component should follow these conventions:
- Accept a
stateprop for the current value - Accept an
onChangeprop for updating the value - Accept any additional props specific to the setting type (e.g.,
options,min,max) - Handle user input and call
onChangewith the new value
Creating a Basic Component
Here's an example of a basic Svelte component for a custom setting type:
<!-- src/interface/components/MyCustomSetting.svelte -->
<script lang="ts">
// Current value
export let state: any = null;
// Callback for updates
export let onChange = (newValue: any) => {};
// Other props specific to your setting type
export let customOption: string = "default";
// Local state or methods if needed
function handleChange(event: Event) {
const value = (event.target as HTMLInputElement).value;
onChange(value);
}
</script>
<div class="my-custom-setting">
<input
type="text"
value={state}
on:input={handleChange}
data-option={customOption}
/>
</div>
<style>
.my-custom-setting {
/* Your component styles */
}
</style>
Example: Slider Component
BetterSEQTA+ includes a Slider component for number settings:
<!-- src/interface/components/Slider.svelte -->
<script lang="ts">
export let state: number | string = 0;
export let onChange = (value: number) => {};
export let min = 0;
export let max = 100;
export let step = 1;
let stringValue = typeof state === "string" ? state : state.toString();
function handleChange(e: Event) {
const input = e.target as HTMLInputElement;
const newValue = parseFloat(input.value);
stringValue = input.value;
onChange(newValue);
}
</script>
<div class="relative flex items-center">
<input
type="range"
class="w-24 accent-indigo-500"
min={min}
max={max}
step={step}
value={state}
on:input={handleChange}
/>
<span class="ml-2 text-xs text-zinc-500 dark:text-zinc-400 w-8">{stringValue}</span>
</div>
Example: Color Picker Component
Here's a more complex example of a color picker component:
<!-- src/interface/components/ColorPicker.svelte -->
<script lang="ts">
export let state = "#000000";
export let onChange = (value: string) => {};
export let presets: string[] = ["#ff0000", "#00ff00", "#0000ff"];
let isOpen = false;
function handleColorChange(e: Event) {
const input = e.target as HTMLInputElement;
onChange(input.value);
}
function selectPreset(color: string) {
onChange(color);
isOpen = false;
}
function togglePicker() {
isOpen = !isOpen;
}
</script>
<div class="color-picker relative">
<button
class="color-swatch"
style="background-color: {state}"
on:click={togglePicker}
aria-label="Open color picker"
></button>
{#if isOpen}
<div class="picker-popup">
<input
type="color"
value={state}
on:input={handleColorChange}
/>
<div class="presets">
{#each presets as preset}
<button
class="preset-swatch"
style="background-color: {preset}"
on:click={() => selectPreset(preset)}
aria-label={`Select color ${preset}`}
></button>
{/each}
</div>
</div>
{/if}
</div>
<style>
.color-picker {
position: relative;
}
.color-swatch {
width: 2rem;
height: 1.5rem;
border-radius: 0.25rem;
border: 1px solid #ccc;
cursor: pointer;
}
.picker-popup {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.5rem;
padding: 0.5rem;
background-color: white;
border: 1px solid #ccc;
border-radius: 0.25rem;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
z-index: 10;
}
.presets {
display: flex;
gap: 0.25rem;
margin-top: 0.5rem;
}
.preset-swatch {
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
border: 1px solid #ccc;
cursor: pointer;
}
</style>
Integrating with the Settings System
Once you've created your component, you need to update general.svelte to use it for your custom setting type.
1. Import Your Component
At the top of src/interface/pages/settings/general.svelte, add an import for your component:
import ColorPicker from "../../components/ColorPicker.svelte"
2. Update Component Mapping
Find the getPluginSettingEntries function in general.svelte and update the component mapping:
function getPluginSettingEntries() {
const entries: any[] = [];
pluginSettings.forEach(plugin => {
if (Object.keys(plugin.settings).length === 0) return;
Object.entries(plugin.settings).forEach(([key, setting]) => {
const id = getPluginSettingId(plugin.pluginId, key);
entries.push({
title: setting.title || key,
description: setting.description || '',
id,
Component: setting.type === 'boolean' ? Switch :
setting.type === 'select' ? Select :
setting.type === 'number' ? Slider :
setting.type === 'color' ? ColorPicker : // Add your component here
setting.type === 'string' ? (setting.options ? Select : null) : Switch,
props: {
state: pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default,
onChange: (value: any) => {
updatePluginSetting(plugin.pluginId, key, value);
},
options: setting.options,
// Add any additional props your component needs
presets: setting.presets,
min: setting.min,
max: setting.max,
step: setting.step
}
});
});
});
return entries;
}
Handling Different UI Needs
Different setting types may have different UI needs:
Toggle Switches
For boolean settings, a toggle switch is usually appropriate:
<script lang="ts">
export let state = false;
export let onChange = (value: boolean) => {};
</script>
<button
class="switch"
class:active={state}
on:click={() => onChange(!state)}
>
<div class="toggle"></div>
</button>
<style>
.switch {
position: relative;
width: 40px;
height: 24px;
background-color: #ccc;
border-radius: 12px;
cursor: pointer;
}
.switch.active {
background-color: #4CAF50;
}
.toggle {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background-color: white;
border-radius: 50%;
transition: transform 0.2s;
}
.switch.active .toggle {
transform: translateX(16px);
}
</style>
Text Inputs
For string settings, a text input with validation:
<script lang="ts">
export let state = "";
export let onChange = (value: string) => {};
export let maxLength: number | undefined = undefined;
export let pattern: string | undefined = undefined;
let error = "";
function validate(value: string) {
if (maxLength && value.length > maxLength) {
error = `Value must be under ${maxLength} characters`;
return false;
}
if (pattern && !new RegExp(pattern).test(value)) {
error = "Value doesn't match the required pattern";
return false;
}
error = "";
return true;
}
function handleInput(e: Event) {
const input = e.target as HTMLInputElement;
const newValue = input.value;
if (validate(newValue)) {
onChange(newValue);
}
}
</script>
<div class="text-input">
<input
type="text"
value={state}
on:input={handleInput}
maxlength={maxLength}
pattern={pattern}
/>
{#if error}
<div class="error">{error}</div>
{/if}
</div>
<style>
.text-input {
position: relative;
}
input {
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 0.25rem;
}
.error {
color: red;
font-size: 0.75rem;
margin-top: 0.25rem;
}
</style>
Complex Interactive Components
For more complex settings, you may need more interactive components with dropdowns, modals, or other features. Consider using additional Svelte features like:
{#if}...{/if}blocks for conditional rendering- Svelte transitions for animations
- Svelte actions for DOM interactions
- Svelte stores for shared state
Best Practices
- Keep Components Focused: Each component should do one thing well
- Use TypeScript: Define proper types for your props
- Handle Errors: Validate input and show meaningful error messages
- Use Clear UI: Make it obvious how to interact with the component
- Add Accessibility: Include proper ARIA attributes and keyboard handling
- Support Theming: Use CSS variables or design system tokens for consistent styling
- Test Edge Cases: Ensure your component handles all possible inputs
Complete Example
Here's a complete example of a custom file picker component:
<!-- src/interface/components/FilePicker.svelte -->
<script lang="ts">
export let state: string | null = null;
export let onChange = (value: string | null) => {};
export let accept = ".txt,.pdf,.doc,.docx";
export let maxSize = 1024 * 1024 * 5; // 5MB
let error = "";
let fileName = state ? state.split('/').pop() : "No file selected";
let inputEl: HTMLInputElement;
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement;
const files = input.files;
if (!files || files.length === 0) {
onChange(null);
fileName = "No file selected";
error = "";
return;
}
const file = files[0];
// Validate file size
if (file.size > maxSize) {
error = `File too large. Maximum size is ${maxSize / (1024 * 1024)}MB.`;
input.value = "";
return;
}
error = "";
fileName = file.name;
// Read file as data URL
const reader = new FileReader();
reader.onload = (e) => {
if (e.target && typeof e.target.result === 'string') {
onChange(e.target.result);
}
};
reader.readAsDataURL(file);
}
function clearFile() {
if (inputEl) inputEl.value = "";
onChange(null);
fileName = "No file selected";
error = "";
}
</script>
<div class="file-picker">
<div class="file-input">
<button class="browse-btn" on:click={() => inputEl.click()}>
Browse...
</button>
<span class="file-name">{fileName}</span>
{#if state}
<button class="clear-btn" on:click={clearFile}>
✕
</button>
{/if}
</div>
<input
type="file"
bind:this={inputEl}
on:change={handleFileChange}
{accept}
class="hidden"
/>
{#if error}
<div class="error">{error}</div>
{/if}
</div>
<style>
.file-picker {
width: 100%;
}
.file-input {
display: flex;
align-items: center;
border: 1px solid #ccc;
border-radius: 0.25rem;
padding: 0.25rem;
}
.browse-btn {
background-color: #f0f0f0;
border: 1px solid #ccc;
border-radius: 0.25rem;
padding: 0.25rem 0.5rem;
margin-right: 0.5rem;
cursor: pointer;
}
.file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.875rem;
}
.clear-btn {
color: #999;
background: none;
border: none;
cursor: pointer;
padding: 0 0.5rem;
}
.hidden {
display: none;
}
.error {
color: red;
font-size: 0.75rem;
margin-top: 0.25rem;
}
</style>
To use this in the settings system, you would:
- Define a
FileSettinginterface intypes.ts - Create a
FileSettingdecorator insettings.ts - Update the
getPluginSettingEntriesfunction ingeneral.svelte
This component demonstrates:
- Handling file input (a more complex input type)
- Input validation
- Error handling
- Multiple interactive elements
- Binding to DOM elements
- Clean UI that follows platform conventions