Skip to content

Commit

Permalink
tmp: stacked context for page styles
Browse files Browse the repository at this point in the history
A PoC of the stacked context paradigm where any edits are reset by the
consuming component’s `onDestroy` lifecycle hook.

The example sets the page background using both `+page`s and `+layout`s:

1. /(voters)/+layout: init the context with default bg-base-100
2. /(voters)/+page: base-300 for the front page
3. /questions/+layout: base-300 for the whole questions route
4. /questions/[categoryId]/+page base-100 for just the category intro
   pages
5. /(voters)/results/[entityType]/[entityId]/+page base-300 for the
   entity detail page

NB. Ultimately, we would init the context in `/+layout` but we're
making changes now only to the Voter App.
  • Loading branch information
kaljarv committed Oct 22, 2024
1 parent 6de1ffa commit 319e0c7
Show file tree
Hide file tree
Showing 8 changed files with 164 additions and 54 deletions.
68 changes: 68 additions & 0 deletions frontend/src/lib/contexts/layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {setContext, getContext, hasContext} from 'svelte';
import {deepMerge} from '$lib/utils/merge';
import {stackedStore, type StackedStore} from '$lib/utils/stackedStore';
import {error} from '@sveltejs/kit';

const LAYOUT_CONTEXT_KEY = Symbol();

export const DEFAULT_PAGE_STYLES: PageStyles = {
drawer: {
background: 'bg-base-100'
}
} as const;

/**
* Initialize and return the context. This must be called before `getLayoutContext()` and cannot be called twice.
* @returns The context object
*/
export function initLayoutContext(): LayoutContext {
if (hasContext(LAYOUT_CONTEXT_KEY)) error(500, 'InitLayoutContext() called for a second time');

const pageStyles = stackedStore<PageStyles, DeepPartial<PageStyles>>(
DEFAULT_PAGE_STYLES,
(current, value) => [
...current,
deepMerge(structuredClone(current[current.length - 1]), structuredClone(value))
]
);
// We can add more reversion actions here when needed
const revert = (index: number) => pageStyles.revert(index);

return setContext<LayoutContext>(LAYOUT_CONTEXT_KEY, {pageStyles, revert});
}

/**
* Get the `LayoutContext` object.
* @param onDestroy - The `onDestroy` callback for the component using the context. This is needed for automatic rolling back of any changes made to page styles or other context properties affecting layout.
* @returns The `LayoutContext` object
*/
export function getLayoutContext(onDestroy: (fn: () => unknown) => void) {
if (!hasContext(LAYOUT_CONTEXT_KEY))
error(500, 'GetLayoutContext() called before initLayoutContext()');
const ctx = getContext<LayoutContext>(LAYOUT_CONTEXT_KEY);
const currentIndex = ctx.pageStyles.getLength() - 1;
onDestroy(() => ctx.revert(currentIndex));
return ctx;
}

export type LayoutContext = {
/**
* An store containing CSS classes used to customize different parts of the layout.
*/
pageStyles: StackedStore<PageStyles, DeepPartial<PageStyles>>;
/**
* Called to revert any changes made to the `LayoutContext`. It will be automatically called when the component is destroyed.
* @param index - The index in the stack that the changes should be rolled back to.
*/
revert: (index: number) => void;
};

type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

interface PageStyles {
drawer: {
background: 'bg-base-100' | 'bg-base-300';
};
}
62 changes: 62 additions & 0 deletions frontend/src/lib/utils/stackedStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {error} from '@sveltejs/kit';
import {writable, derived, get, type Writable, type Readable} from 'svelte/store';

/**
* Create a simple stacked store which a appends items to an internally stored stack when set. The store can be reverted to any previous state with the `revert(index)` function.
* @param initialValue - The first item of the stack.
*/
export function simpleStackedStore<TItem>(initialValue: TItem): StackedStore<TItem> {
return stackedStore(initialValue, (current, value) => [...current, value]);
}

/**
* Create a stacked store which a appends items to an internally stored stack when set. The store can be reverted to any previous state with the `revert(index)` function.
* @param initialValue - The first item of the stack.
* @param updater - The function which is used to update the stack with new items.
*/
export function stackedStore<TMerged, TAddition = TMerged>(
initialValue: TMerged,
updater: (current: Array<TMerged>, value: TAddition) => Array<TMerged>
): StackedStore<TMerged, TAddition> {
const stack = writable<Array<TMerged>>([initialValue]);
const {subscribe} = derived(stack, ($stack) => $stack[$stack.length - 1]);

const revert = (index: number): TMerged => {
if (index < 0) error(500, 'StackedStore.revert: index cannot be negative');
const current = get(stack);
if (index < current.length - 1) {
current.splice(index + 1);
stack.set(current);
}
return current[current.length - 1];
};
const set = (value: TAddition): void => stack.update((s) => updater(s, value));
const getLength = (): number => get(stack).length;

return {getLength, revert, set, subscribe};
}

/**
* @typeParam TMerged - The type of the merged items in the stack, i.e., the type that is returned when subscribing to the stack.
* @typeParam TAddition - The type of the items that can be added to the stack, which may differ from `TMerged`, e.g. by `Partial<TMerged>`.
*/
export type StackedStore<TMerged, TAddition = TMerged> = {
/**
* @returns The current length of the stack.
*/
getLength: () => number;
/**
* Revert the stack to the item at the given index.
* @param index - The index of the item to revert to. If the index is out of bounds, the stack remains unchanged, but the last item is still returned.
* @returns The last item in the stack after reverting.
*/
revert: (index: number) => TMerged;
/**
* Add another item to the stack.
*/
set: Writable<TAddition>['set'];
/**
* Subscribe to the last item in the stack.
*/
subscribe: Readable<TMerged>['subscribe'];
};
13 changes: 6 additions & 7 deletions frontend/src/routes/[[lang=locale]]/(voters)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@
import {tweened} from 'svelte/motion';
import {cubicOut} from 'svelte/easing';
import {setTopBarProgressContext} from '../topBarProgress.context';
import {getPageStylesContext, resetPageStylesContext, setPageStylesContext} from '../context';
import {navigating} from '$app/stores';
import {initLayoutContext} from '$lib/contexts/layout';
export let drawerToggleId: BasicPageProps['drawerToggleId'] = 'pageDrawerToggle';
export let mainId: BasicPageProps['mainId'] = 'mainContent';
Expand Down Expand Up @@ -76,11 +75,11 @@
easing: cubicOut
})
});
setPageStylesContext();
$: if ($navigating) resetPageStylesContext();
const pageStyle = getPageStylesContext();
/**
* Init the context for layout changes.
*/
const {pageStyles} = initLayoutContext();
$appType = 'voter';
Expand Down Expand Up @@ -110,7 +109,7 @@
<a href="#{mainId}" tabindex="1" class="sr-only focus:not-sr-only">{$t('common.skipToMain')}</a>

<!-- Drawer container -->
<div class="drawer {$pageStyle.drawer.background}">
<div class="drawer {$pageStyles.drawer.background}">
<!-- NB. The Wave ARIA checker will show an error for this, but the use of both the
non-hidden labels in aria-labelledby should be okay for screen readers. -->
<input
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/routes/[[lang=locale]]/(voters)/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@
import Layout from '../layout.svelte';
import Footer from '$lib/templates/parts/footer/Footer.svelte';
// TEST: LayoutContext
import {onDestroy} from 'svelte';
import {getLayoutContext} from '$lib/contexts/layout';
const {pageStyles} = getLayoutContext(onDestroy);
$pageStyles = {drawer: {background: 'bg-base-300'}};
// END TEST
resetTopBarContext({
imageSrc: $darkMode
? ($customization.posterDark?.url ?? '/images/hero.png')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
import {getRoute, Route} from '$lib/utils/navigation';
import {LoadingSpinner} from '$candidate/components/loadingSpinner';
// TEST: LayoutContext
import {onDestroy} from 'svelte';
import {getLayoutContext} from '$lib/contexts/layout';
const {pageStyles} = getLayoutContext(onDestroy);
$pageStyles = {drawer: {background: 'bg-base-300'}};
// END TEST
export let data;
$: data.opinionQuestions.then((qs) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@
import type {PageData} from './$types';
import {getTopBarProgressContext} from '../../../../topBarProgress.context';
// TEST: LayoutContext
import {onDestroy} from 'svelte';
import {getLayoutContext} from '$lib/contexts/layout';
const {pageStyles} = getLayoutContext(onDestroy);
$pageStyles = {drawer: {background: 'bg-base-100'}};
// END TEST
/**
* A page for showing a category's introduction page.
* TODO: This has a lot of overlap with the single question page, but combining them would be a mess with template slots. Both this and the question page should be thoroughly refactored when the slotless page templates are available and the app state management is more coherent.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@
import type {Readable} from 'svelte/store';
import {resetTopBarActionsContext} from '../../../../topBarActions.context';
import {resetTopBarContext} from '../../../../topBar.context';
import {resetPageStylesContext} from '../../../../context';
// TEST: LayoutContext
import {onDestroy} from 'svelte';
import {getLayoutContext} from '$lib/contexts/layout';
const {pageStyles} = getLayoutContext(onDestroy);
$pageStyles = {drawer: {background: 'bg-base-300'}};
// END TEST
export let data;
Expand All @@ -35,11 +41,6 @@
returnButtonLabel: $t('common.back'),
returnButtonCallback: () => (useBack ? history.back() : goto($getRoute(Route.Results)))
});
resetPageStylesContext({
drawer: {
background: 'bg-base-300'
}
});
// We need to set these reactively to get the most recent param data. We should, however, check that data has actually changed before reloading anything.
$: {
Expand Down
41 changes: 0 additions & 41 deletions frontend/src/routes/[[lang=locale]]/context.ts

This file was deleted.

0 comments on commit 319e0c7

Please sign in to comment.