Skip to content

Commit

Permalink
useStateHistory & usePrevious (#33)
Browse files Browse the repository at this point in the history
* use state history demo

* use previous

* add jsdoc @see

* dummy test

* document use state history

* change array order

* Create curly-balloons-marry.md
  • Loading branch information
TGlide authored May 1, 2024
1 parent 5b3c8e8 commit 667d6a8
Show file tree
Hide file tree
Showing 32 changed files with 408 additions and 43 deletions.
5 changes: 5 additions & 0 deletions .changeset/curly-balloons-marry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"runed": patch
---

feat: `useStateHistory` & `usePrevious`
62 changes: 39 additions & 23 deletions packages/runed/src/lib/functions/box/box.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Expand, Getter } from "$lib/internal/types.js";
import type { Expand, Getter, MaybeBoxOrGetter } from "$lib/internal/types.js";
import { isFunction, isObject } from "$lib/internal/utils/is.js";

const BoxSymbol = Symbol("box");
Expand All @@ -16,12 +16,16 @@ export interface WritableBox<T> extends ReadableBox<T> {

/**
* @returns Whether the value is a Box
*
* @see {@link https://runed.dev/docs/functions/box}
*/
function isBox(value: unknown): value is ReadableBox<unknown> {
return isObject(value) && BoxSymbol in value;
}
/**
* @returns Whether the value is a WritableBox
*
* @see {@link https://runed.dev/docs/functions/box}
*/
function isWritableBox(value: unknown): value is WritableBox<unknown> {
return box.isBox(value) && isWritableSymbol in value;
Expand All @@ -32,6 +36,8 @@ function isWritableBox(value: unknown): value is WritableBox<unknown> {
*
* @returns A box with a `value` property which can be set to a new value.
* Useful to pass state to other functions.
*
* @see {@link https://runed.dev/docs/functions/box}
*/
export function box<T>(): WritableBox<T | undefined>;
/**
Expand All @@ -40,6 +46,8 @@ export function box<T>(): WritableBox<T | undefined>;
* @param initialValue The initial value of the box.
* @returns A box with a `value` property which can be set to a new value.
* Useful to pass state to other functions.
*
* @see {@link https://runed.dev/docs/functions/box}
*/
export function box<T>(initialValue: T): WritableBox<T>;
export function box(initialValue?: unknown) {
Expand All @@ -63,6 +71,8 @@ export function box(initialValue?: unknown) {
* @param getter Function to get the value of the box
* @returns A box with a `value` property whose value is the result of the getter.
* Useful to pass state to other functions.
*
* @see {@link https://runed.dev/docs/functions/box}
*/
function boxWith<T>(getter: () => T): ReadableBox<T>;
/**
Expand All @@ -72,6 +82,8 @@ function boxWith<T>(getter: () => T): ReadableBox<T>;
* @param setter Function to set the value of the box
* @returns A box with a `value` property which can be set to a new value.
* Useful to pass state to other functions.
*
* @see {@link https://runed.dev/docs/functions/box}
*/
function boxWith<T>(getter: () => T, setter: (v: T) => void): WritableBox<T>;
function boxWith<T>(getter: () => T, setter?: (v: T) => void) {
Expand All @@ -98,25 +110,25 @@ function boxWith<T>(getter: () => T, setter?: (v: T) => void) {
};
}

export type BoxFrom<T> =
T extends WritableBox<infer U>
? WritableBox<U>
: T extends ReadableBox<infer U>
? ReadableBox<U>
: T extends Getter<infer U>
? ReadableBox<U>
: WritableBox<T>;


/**
* Creates a box from either a static value, a box, or a getter function.
* Useful when you want to receive any of these types of values and generate a boxed version of it.
*
* @returns A box with a `value` property whose value.
*
* @see {@link https://runed.dev/docs/functions/box}
*/
function boxFrom<T>(value: T): BoxFrom<T> {
if (box.isBox(value)) return value as BoxFrom<T>;
if (isFunction(value)) return box.with(value) as BoxFrom<T>;
return box(value) as BoxFrom<T>;
function boxFrom<T>(value: T | WritableBox<T>): WritableBox<T>;
function boxFrom<T>(value: ReadableBox<T>): ReadableBox<T>;
function boxFrom<T>(value: Getter<T>): ReadableBox<T>;
function boxFrom<T>(value: MaybeBoxOrGetter<T>): ReadableBox<T>
function boxFrom<T>(value: T): WritableBox<T>;
function boxFrom<T>(value: MaybeBoxOrGetter<T>) {
if (box.isBox(value)) return value;
if (isFunction(value)) return box.with(value);
return box(value)
}

type GetKeys<T, U> = {
Expand All @@ -131,16 +143,16 @@ type BoxFlatten<R extends Record<string, unknown>> = Expand<
},
never
> &
RemoveValues<
{
readonly [K in keyof R]: R[K] extends WritableBox<infer _>
? never
: R[K] extends ReadableBox<infer T>
? T
: never;
},
never
>
RemoveValues<
{
readonly [K in keyof R]: R[K] extends WritableBox<infer _>
? never
: R[K] extends ReadableBox<infer T>
? T
: never;
},
never
>
> &
RemoveValues<
{
Expand All @@ -156,6 +168,8 @@ type BoxFlatten<R extends Record<string, unknown>> = Expand<
* const count = box(0)
* const flat = box.flatten({ count, double: box.with(() => count.value) })
* // type of flat is { count: number, readonly double: number }
*
* @see {@link https://runed.dev/docs/functions/box}
*/
function boxFlatten<R extends Record<string, unknown>>(boxes: R): BoxFlatten<R> {
return Object.entries(boxes).reduce<BoxFlatten<R>>((acc, [key, b]) => {
Expand Down Expand Up @@ -191,6 +205,8 @@ function boxFlatten<R extends Record<string, unknown>>(boxes: R): BoxFlatten<R>
* @example
* const count = box(0) // WritableBox<number>
* const countReadonly = box.readonly(count) // ReadableBox<number>
*
* @see {@link https://runed.dev/docs/functions/box}
*/
function toReadonlyBox<T>(b: ReadableBox<T>): ReadableBox<T> {
if (!box.isWritableBox(b)) return b;
Expand Down
7 changes: 7 additions & 0 deletions packages/runed/src/lib/functions/box/box.test.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, expectTypeOf, test } from "vitest";
import { type ReadableBox, type WritableBox, box } from "./box.svelte.js";
import type { MaybeBoxOrGetter } from "$lib/internal/types.js";

describe("box", () => {
test("box with initial value should be settable", () => {
Expand Down Expand Up @@ -171,6 +172,12 @@ describe("box types", () => {
expectTypeOf(count2).toMatchTypeOf<ReadableBox<number>>();
});

test("box from maybe box or getter", () => {
const count = 0 as MaybeBoxOrGetter<number>;
const count2 = box.from(count);
expectTypeOf(count2).toMatchTypeOf<ReadableBox<number>>();
})

test("box.isWritableBox = true should allow box to be settable", () => {
const count = box(0) as WritableBox<number> | ReadableBox<number>;
expectTypeOf(count).toMatchTypeOf<ReadableBox<number>>();
Expand Down
3 changes: 3 additions & 0 deletions packages/runed/src/lib/functions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ export * from "./useElementSize/index.js";
export * from "./useEventListener/index.js";
export * from "./useMounted/index.js";
export * from "./useSupported/index.js";
export * from "./useStateHistory/index.js";
export * from './watch/index.js';
export * from './usePrevious/index.js';
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { isBrowser } from "$lib/internal/utils/browser.js";
*
* @returns an object with a reactive value `value` that is equal to `document.activeElement`,
* or `null` if there's no active element.
*
* @see {@link https://runed.dev/docs/functions/use-active-element}
*/
export function useActiveElement(): ReadableBox<Element | null> {
const activeElement = box(isBrowser() ? document.activeElement : null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import type { FunctionArgs, MaybeGetter } from "$lib/internal/types.js";
* The second parameter is the time to wait before calling the original callback.
* Alternatively, it can also be a getter function that returns the time to wait.
*
*
* @see {@link https://runed.dev/docs/functions/use-debounce}
*/
export function useDebounce<Callback extends FunctionArgs>(
callback: Callback,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export type UseElementSizeOptions = {
* - `box`: The box model to use. Can be either `"content-box"` or `"border-box"`. Defaults to `"border-box"`.
*
* @returns an object with `width` and `height` properties.
*
* @see {@link https://runed.dev/docs/functions/use-element-size}
*/
export function useElementSize(
_node: MaybeBoxOrGetter<HTMLElement | undefined>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { addEventListener } from "$lib/internal/utils/event.js";
* @param event The event(s) to listen for.
* @param handler The function to be called when the event is triggered.
* @param options An optional object that specifies characteristics about the event listener.
*
* @see {@link https://runed.dev/docs/functions/use-event-listener}
*/
export function useEventListener<TEvent extends keyof WindowEventMap>(
target: MaybeBoxOrGetter<Window | null | undefined>,
Expand All @@ -29,6 +31,8 @@ export function useEventListener<TEvent extends keyof DocumentEventMap>(
* @param event The event(s) to listen for.
* @param handler The function to be called when the event is triggered.
* @param options An optional object that specifies characteristics about the event listener.
*
* @see {@link https://runed.dev/docs/functions/use-event-listener}
*/
export function useEventListener<
TElement extends HTMLElement,
Expand All @@ -46,6 +50,8 @@ export function useEventListener<
* @param event The event(s) to listen for.
* @param handler The function to be called when the event is triggered.
* @param options An optional object that specifies characteristics about the event listener.
*
* @see {@link https://runed.dev/docs/functions/use-event-listener}
*/
export function useEventListener(
target: MaybeBoxOrGetter<EventTarget | null | undefined>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { type ReadableBox, box } from "../box/box.svelte.js";
/**
* Returns a box with the mounted state of the component
* that invokes this function.
*
* @see {@link https://runed.dev/docs/functions/use-mounted}
*/
export function useMounted(): ReadableBox<boolean> {
const isMounted = box(false);
Expand Down
1 change: 1 addition & 0 deletions packages/runed/src/lib/functions/usePrevious/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './usePrevious.svelte.js'
21 changes: 21 additions & 0 deletions packages/runed/src/lib/functions/usePrevious/usePrevious.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { untrack } from "svelte";
import { box } from "../index.js";
import type { BoxOrGetter } from "$lib/internal/types.js";

/**
* Holds the previous value of a box or getter.
*
* @see {@link https://runed.dev/docs/functions/use-previous}
*/
export function usePrevious<T>(value: BoxOrGetter<T>) {
const boxed = box.from(value);
let curr: T | undefined = $state()
const previous = box<T | undefined>(undefined);

$effect(() => {
previous.value = untrack(() => curr);
curr = boxed.value;
});

return previous;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe } from "node:test";
import { expect, test } from "vitest";
import { box } from "../index.js";
import { usePrevious } from "./usePrevious.svelte.js";
import { testWithEffect } from "$lib/test/util.svelte.js";

// TODO: Find out why tests aren't working, even though the demo works
describe('usePrevious', () => {
test('dummy test', () => {
expect(true).toBe(true)
})
// testWithEffect('Should return undefined initially', () => {
// const previous = usePrevious(() => 0);
// expect(previous.value).toBe(undefined)
// })

// testWithEffect('Should return previous value', async () => {
// const count = box(0);
// const previous = usePrevious(count);
// expect(previous.value).toBe(undefined)
// count.value = 1
// await new Promise(resolve => setTimeout(resolve, 100))
// expect(previous.value).toBe(0)
// count.value = 2
// expect(previous.value).toBe(1)
// })
})
1 change: 1 addition & 0 deletions packages/runed/src/lib/functions/useStateHistory/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useStateHistory.svelte.js'
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { type WritableBox, box } from "../box/box.svelte.js";
import { watch } from "../watch/watch.svelte.js";
import type { MaybeBoxOrGetter } from "$lib/internal/types.js";

type UseStateHistoryOptions = {
capacity?: MaybeBoxOrGetter<number>
}

type LogEvent<T> = {
snapshot: T
timestamp: number
}

/**
* Tracks the change history of a box, providing undo and redo capabilities.
*
* @see {@link https://runed.dev/docs/functions/use-state-history}
*/
export function useStateHistory<T>(b: WritableBox<T>, options?: UseStateHistoryOptions) {
const capacity = box.from(options?.capacity)

const log = box<LogEvent<T>[]>([])
const redoStack = box<LogEvent<T>[]>([])

const canUndo = box.with(() => log.value.length > 1)
const canRedo = box.with(() => redoStack.value.length > 0)

let ignoreUpdate = false;

function addEvent(event: LogEvent<T>) {
log.value.push(event)
if (capacity.value && log.value.length > capacity.value) {
log.value = log.value.slice(-capacity.value)
}
}

watch(() => b.value, (v) => {
if (ignoreUpdate) {
ignoreUpdate = false
return
}

addEvent({ snapshot: v, timestamp: new Date().getTime() })
redoStack.value = []
})

watch(() => capacity.value, (c) => {
if (!c) return;
log.value = log.value.slice(-c)
})


function undo() {
const [prev, curr] = log.value.slice(-2)
if (!curr || !prev) return;
ignoreUpdate = true;
redoStack.value.push(curr)
log.value.pop()
b.value = prev.snapshot
}

function redo() {
const nextEvent = redoStack.value.pop()
if (!nextEvent) return;
ignoreUpdate = true;
addEvent(nextEvent)
b.value = nextEvent.snapshot
}


return box.flatten({
log,
undo,
canUndo,
redo,
canRedo
})

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { describe, expect, test } from "vitest"


describe("useStateHistory", () => {
test("dummy test", () => {
expect(true).toBe(true)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { type ReadableBox, box } from "../box/box.svelte.js";
* // do something with navigator
* }
* ```
*
* @see {@link https://runed.dev/docs/functions/use-supported}
*/
export function useSupported(predicate: () => boolean): ReadableBox<boolean> {
const isSupported = box(false);
Expand Down
1 change: 1 addition & 0 deletions packages/runed/src/lib/functions/watch/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './watch.svelte.js'
Loading

0 comments on commit 667d6a8

Please sign in to comment.