Refactoring composite interface part1

This commit is contained in:
Samuel Andert 2023-08-03 14:03:02 +02:00
parent 37b915cef6
commit dcb94e7c58
8 changed files with 399 additions and 180 deletions

View File

@ -1,180 +0,0 @@
TYPESCRIPT
Copy
import { RangeSlider } from '@skeletonlabs/skeleton';
Source
Page Source
WAI-ARIA
Usage
Props
Slots
Events
Keyboard
Demo
Combines a native range input with datalist ticks to create a powerful range slider element.
Labeled
TYPESCRIPT
Copy
let value = 15;
let max = 25;
HTML
Copy
<RangeSlider name="range-slider" bind:value={value} max={25} step={1} ticked>
<div class="flex items-center justify-between">
<div class="font-bold">Label</div>
<div class="text-xs">{value} / {max}</div>
</div>
</RangeSlider>
This component implements restProps. This passes all extra attributes to the component's input elements.
Name Type Value Description
name string - Required. Set a unique name for the input.
id string - Provide a unique input id. Auto-generated by default
value number 0 Set the input value.
min number 0 Set the input minimum range.
max number 100 Set the input maximum range.
step number 1 Set the input step offset.
ticked boolean false Enables tick marks. See browser support below.
accent string 'accent-surface-900 dark:accent-surface-50' Provide classes to set the input accent color.
label string - A semantic ARIA label.
Usage
Props
Slots
Events
Keyboard
Name Default Fallback Description
default ✓ - -
trail - - A label slot directly below the range slider.
Source
Page Source
WAI-ARIA
Usage
Props
Slots
Events
Keyboard
Name Type Element Response Description
on:click forwarded <input> - -
on:change forwarded <input> - -
on:blur forwarded <input> - -
Source
Page Source
WAI-ARIA
Usage
Props
Slots
Events
Keyboard
Keys Description
Right Arrow or Up Arrow Increase the value of the slider by one step.
Left Arrow or Down Arrow Decrease the value of the slider by one step.
Home Set the slider to the first allowed value in its range.
End Set the slider to the last allowed value in its range.
Page Up Increase the slider value by a large amount.
Page Down Decrease the slider value by a large amount.
Slide Toggles
A sliding toggle element that can capture input from a user.
TYPESCRIPT
Copy
import { SlideToggle } from '@skeletonlabs/skeleton';
Source
Page Source
WAI-ARIA
Usage
Props
Slots
Events
Demo
TYPESCRIPT
Copy
let value: boolean = false;
HTML
Copy
<SlideToggle name="slide" bind:checked={value} />
This component provides an alternative UI for a checkbox input element.
Labeled
HTML
Copy
<SlideToggle name="slider-label" checked>(label)</SlideToggle>
Customized
Slide toggles styles and colors can be easily customized with the active and size properties.
HTML
Copy
<SlideToggle name="slider-large" checked active="bg-primary-500" size="lg" />
Checkbox Attributes
This component supports Svelte's $$restProps, which allows for required, disabled, and any other valid checkbox input attributes.
HTML
Copy
<SlideToggle ... required disabled />
Usage
Props
Slots
Events
This component implements restProps. This passes all extra attributes to the component's input elements.
Name Type Value Description
name string - Required. Set a unique name for the input.
checked boolean false The checked state of the input element.
size 'sm' | 'md' | 'lg' 'md' Sets the size of the component.
background string 'bg-surface-400 dark:bg-surface-700' Provide classes to set the inactive state background color.
active string 'bg-surface-900 dark:bg-surface-300' Provide classes to set the active state background color.
border string - Provide classes to set the border styles.
rounded string 'rounded-full' Provide classes to set border radius styles.
label string - Provide a semantic label.
TYPESCRIPT
Copy
import { SlideToggle } from '@skeletonlabs/skeleton';
Source
Page Source
WAI-ARIA
Usage
Props
Slots
Events
Name Default Fallback Description
default ✓ - -
Slide Toggles
A sliding toggle element that can capture input from a user.
TYPESCRIPT
Copy
import { SlideToggle } from '@skeletonlabs/skeleton';
Source
Page Source
WAI-ARIA
Usage
Props
Slots
Events
Name Type Element Response Description
on:keyup dispatched - { event } Fires when the component is focused and key is pressed.
on:click forwarded <input> - -
on:keydown forwarded <input> - -
on:keypress forwarded <input> - -
on:mouseover forwarded <input> - -
on:change forwarded <input> - -
on:focus forwarded <input> - -
on:blur forwarded <input> - -

View File

@ -0,0 +1,27 @@
<script>
export let services;
export let store;
export let machineService;
let childStore;
$: if (services.core) {
childStore = services.core.subscribeComposer('@ComposerBob');
}
$: {
if ($childStore && $childStore.machine.state) {
console.log('learn color machine: ' + JSON.stringify(machineService));
machineService.send($childStore.machine.state);
}
}
</script>
<div class="p-2 border-2 border-blue-500">
I am the parent, this is my state: {$store.machine.state}
<div
class="p-2 border-2"
style="background-color: {$store.machine.state}; border-radius: 50%; width: 50px; height: 50px;"
/>
</div>

View File

@ -0,0 +1,18 @@
<script>
export let store;
export let machineService;
const handleButton = () => {
console.log('learn ready machine: ' + JSON.stringify(machineService));
console.log('Sending TOGGLE event to the machine');
machineService.send('TOGGLE');
};
</script>
<div class="border-2 border-yellow-500">
i am the child and this is my state: {$store.machine.state}
<button
class="px-4 py-2 font-bold text-white bg-blue-500 rounded hover:bg-blue-700"
on:click={handleButton}>Switch</button
>
</div>

View File

@ -0,0 +1,252 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import Composer from './Composer.svelte';
import FallBack from './FallBack.svelte';
import components from '$lib/core/componentLoader';
import services from '$lib/core/servicesLoader';
import { dataStore } from '$lib/core/dataLoader';
import { createComposerStore, getComposerStore } from './composerStores';
import { coreServices } from './coreServices';
import { Machine, interpret } from 'xstate';
interface IComposerLayout {
areas: string;
columns?: string;
rows?: string;
gap?: string;
tailwindClasses?: string;
}
interface IComposer {
layout?: IComposerLayout;
id: string;
slot?: string;
component?: string;
services?: string[];
map?: Record<string, string>;
store?: Record<string, any>;
children?: IComposer[];
servicesLoaded?: boolean;
machine?: any;
machineService?: any;
}
export let composer: IComposer;
let loadedServices: Record<string, any> = {
core: coreServices
};
let layoutStyle = '';
let machineService;
$: {
layoutStyle = computeLayoutStyle(composer?.layout);
initializeAndLoadServices(composer);
initializeComposerState(composer);
mapAndSubscribe(composer);
if (composer?.children) {
composer.children.forEach((child) => {
initializeComposerState(child);
initializeAndLoadServices(child);
mapAndSubscribe(child);
});
}
if (composer?.machine) {
const machine = Machine({ ...composer.machine, id: composer.id });
machineService = interpret(machine).onTransition((state) => {
getComposerStore(composer.id).update((storeValue) => ({
...storeValue,
machine: { state: state.value }
}));
});
machineService.start();
composer.machineService = machineService;
}
if (composer?.children) {
composer.children.forEach((child) => {
if (child.machine) {
const childMachine = Machine({ ...child.machine, id: child.id });
machineService = interpret(childMachine).onTransition((state) => {
getComposerStore(child.id).update((storeValue) => ({
...storeValue,
machine: { state: state.value }
}));
});
machineService.start();
child.machineService = machineService;
}
});
}
}
function computeLayoutStyle(layout?: IComposerLayout): string {
if (!layout) return '';
return `
grid-template-areas: ${layout.areas};
${layout.gap ? `gap: ${layout.gap};` : ''}
${layout.columns ? `grid-template-columns: ${layout.columns};` : ''}
${layout.rows ? `grid-template-rows: ${layout.rows};` : ''}
`;
}
function initializeAndLoadServices(component: IComposer) {
if (!component) return;
if (component.services) {
let servicePromises = component.services.map((serviceName) =>
loadedServices[serviceName]
? Promise.resolve(loadedServices[serviceName])
: loadService(serviceName)
);
Promise.all(servicePromises).then((loaded) => {
loaded.forEach((service, index) => {
loadedServices[component.services[index]] = service;
});
component.servicesLoaded = true;
});
} else {
component.servicesLoaded = true;
}
}
function initializeComposerState(child: IComposer) {
if (!child) return; // Add this line
if (child.id) {
child.store = createComposerStore(child.id, child.store || {});
}
if (child.children) {
child.children.forEach(initializeComposerState);
}
}
let unsubscribers = [];
function mapAndSubscribe(component: IComposer) {
if (!component) return;
if (component.map) {
const localStore = getComposerStore(component.id);
for (const [localKey, mapping] of Object.entries(component.map)) {
const isDataMapping = mapping.startsWith('@data:');
const isStoreMapping = mapping.startsWith('@');
if (isDataMapping) {
const externalKey = mapping.replace('@data:', '').trim();
const unsubscribe = dataStore.subscribe((store) => {
if (externalKey in store) {
if (store[externalKey] && typeof store[externalKey].subscribe === 'function') {
let innerUnsub = store[externalKey].subscribe((value) => {
localStore.update((storeValue) => ({ ...storeValue, [localKey]: value }));
});
unsubscribers.push(innerUnsub);
} else {
localStore.update((storeValue) => ({
...storeValue,
[localKey]: store[externalKey]
}));
}
}
});
unsubscribers.push(unsubscribe);
} else if (isStoreMapping) {
const [externalID, externalKey] = mapping
.replace('@', '')
.split(':')
.map((item) => item.trim());
const externalStore = getComposerStore(externalID);
if (externalStore) {
const unsubscribe = externalStore.subscribe((externalState) => {
if (externalState && externalKey in externalState) {
localStore.update((storeValue) => ({
...storeValue,
[localKey]: externalState[externalKey]
}));
}
});
unsubscribers.push(unsubscribe);
}
}
}
}
if (component.children) {
component.children.forEach(mapAndSubscribe);
}
}
onDestroy(() => {
unsubscribers.forEach((unsub) => unsub());
});
async function getComponent(componentName: string) {
if (components[componentName]) {
const module = await components[componentName]();
return module.default;
}
return FallBack;
}
async function loadService(serviceName: string) {
if (services[serviceName]) {
const module = await services[serviceName]();
return module.default || module;
}
return null;
}
async function loadComponentAndService(component: IComposer) {
const componentName = component.component || 'FallBack';
return await getComponent(componentName);
}
</script>
<div
class={`grid w-full h-full overflow-hidden ${composer?.layout?.tailwindClasses || ''}`}
style={layoutStyle}
>
{#if composer?.servicesLoaded}
{#await loadComponentAndService(composer) then Component}
<svelte:component
this={Component}
id={composer.id}
store={getComposerStore(composer.id)}
machine={composer.machine}
services={loadedServices}
machineService={child.machineService}
/>
{/await}
{/if}
{#if composer?.children}
{#each composer.children as child (child.id)}
<div
class="grid w-full h-full overflow-hidden ${composer?.layout?.tailwindClasses || ''}"
style={`grid-area: ${child.slot}`}
>
{#if child.servicesLoaded}
{#await loadComponentAndService(child) then ChildComponent}
<svelte:component
this={ChildComponent}
id={child.id}
store={getComposerStore(child.id)}
machine={child.machine}
services={loadedServices}
machineService={child.machineService}
/>
{#if child.children && child.children.length}
<Composer composer={child} />
{/if}
{/await}
{/if}
</div>
{/each}
{/if}
</div>

View File

@ -0,0 +1,4 @@
<script>
export let id;
console.log(`FallBack component rendered with id ${id}`);
</script>

View File

@ -0,0 +1,16 @@
import { writable } from 'svelte/store';
const composerStores = new Map();
// Create or retrieve a composer store
export function createComposerStore(composerId: string, initialState = {}) {
if (!composerStores.has(composerId)) {
composerStores.set(composerId, writable(initialState));
}
return composerStores.get(composerId);
}
// Get composer store or create a default empty one if not exists
export function getComposerStore(composerId: string) {
return composerStores.get(composerId) || createComposerStore(composerId);
}

View File

@ -0,0 +1,23 @@
// coreServices.ts
import { getComposerStore } from './composerStores';
export const coreServices = {
updateComposer: (mappings: Record<string, string>) => {
for (const [mappingString, value] of Object.entries(mappings)) {
const [storeID, key] = mappingString.replace('@', '').split(':');
const store = getComposerStore(storeID);
store.update(storeData => {
storeData[key] = value;
return storeData;
});
}
},
subscribeComposer: (mappingString: string) => {
const [storeID] = mappingString.replace('@', '').split(':');
const store = getComposerStore(storeID);
return store;
},
testAlert: () => {
alert("core service alert")
}
};

View File

@ -0,0 +1,59 @@
<script>
import Composer from '$lib/core/refactor/Composer.svelte';
let composer = {
id: 'ComposerParent',
layout: {
columns: '1fr 1fr',
areas: `
"left right"
`
},
children: [
{
id: 'ComposerBob',
component: 'ComposerBob',
slot: 'left',
store: {
machine: { state: 'NOTREADY' }
},
machine: {
initial: 'NOTREADY',
states: {
NOTREADY: {
on: { TOGGLE: 'READY' }
},
READY: {
on: { TOGGLE: 'NOTREADY' }
}
}
}
},
{
id: 'ComposerAlice',
component: 'ComposerAlice',
slot: 'right',
machine: {
initial: 'RED',
states: {
GREEN: {
on: { SWITCH: 'YELLOW' }
},
YELLOW: {
on: { SWITCH: 'RED' }
},
RED: {
on: { SWITCH: 'GREEN' }
}
},
on: {
READY: 'GREEN',
NOTREADY: 'RED'
}
}
}
]
};
</script>
<Composer {composer} />