diff --git a/CHANGELOG.md b/CHANGELOG.md index c494a761..28f6fad5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- Add an Indexable primitive component and a withIndex decorator ([#491](https://github.com/studiometa/ui/pull/491), [0394a1df](https://github.com/studiometa/ui/commit/0394a1df)) + ## [v1.7.0](https://github.com/studiometa/ui/compare/1.6.0..1.7.0) (2025-11-11) ### Added diff --git a/packages/docs/components/Indexable/examples.md b/packages/docs/components/Indexable/examples.md new file mode 100644 index 00000000..ac01a764 --- /dev/null +++ b/packages/docs/components/Indexable/examples.md @@ -0,0 +1,24 @@ +--- +title: Indexable examples +--- + +# Examples + +## Counter + + + + + + +:::code-group + +<<< ./stories/toggle/app.twig +<<< ./stories/toggle/app.js + +::: + + diff --git a/packages/docs/components/Indexable/index.md b/packages/docs/components/Indexable/index.md new file mode 100644 index 00000000..1877d85c --- /dev/null +++ b/packages/docs/components/Indexable/index.md @@ -0,0 +1,77 @@ +--- +badges: [JS] +--- + +# Indexable + +The Indexable primitive provides a robust index management system for components that need to navigate between multiple items. It offers methods to move between indices (`goNext()`, `goPrev()`, `goTo()`), supports different navigation modes (normal, infinite loop, alternate), and emits events when the index changes. It's ideal for building components like sliders, carousels, tabs, or any component that needs to track and navigate through a collection of items. + +It is available as a `Indexable` component as well as a `withIndex(Base)` decorator. + +## Table of content + +- [Examples](./examples) +- [JS API](./js-api) + +## Usage + +As a primitive, the `Indexable` class should be used to create other components rather than being used directly in an application. **Important:** the `length` property must be defined. + +```js +import { Indexable } from '@studiometa/ui'; + +export default class Counter extends Indexable { + static config = { + name: 'Counter', + }; + + get length() { + return 10; + } + + onIndex() { + this.$el.textContent = this.currentIndex; + } +} +``` + +Once you component is created, you can use it in your app and trigger its `goNext` and `goPrev` methods to update its states: + +```js {2,10,13-15,17-19} +import { Base, createApp } from '@studiometa/js-toolkit'; +import Counter from './Counter.js'; + +class App extends Base { + static config = { + name: 'App', + refs: ['prevBtn', 'nextBtn'], + components: { + Counter, + }, + }; + + onPrevBtnClick() { + this.$children.Counter.forEach((instance) => instance.goPrev()); + } + + onNextBtnClick() { + this.$children.Counter.forEach((instance) => instance.goNext()); + } +} + +export default createApp(App); +``` + +You can now add a counter component in your HTML and define the count mode: + +```html + + 0 + + + +``` + +::: tip Example +Checkout the [result of this example](./examples#counter) for a better understanding. +::: diff --git a/packages/docs/components/Indexable/js-api.md b/packages/docs/components/Indexable/js-api.md new file mode 100644 index 00000000..0a36ef39 --- /dev/null +++ b/packages/docs/components/Indexable/js-api.md @@ -0,0 +1,38 @@ +--- +title: Indexable JS API +outline: deep +--- + +# JS API + +## Options + +### `mode` + +- Type: `String` +- Default: `'normal'` + +Three modes are available: `normal`, `infinite`, or `alternate`: + +- **Normal**: stops when the index reaches `maxIndex`. +- **Infinite**: restarts from the beginning when the index reaches `maxIndex`. +- **Alternate**: bounces back and goes backward when the index reaches `maxIndex`. + +```html {2} +
+ ... +
+``` + +### `reverse` + +- Type: `Boolean` +- Default: `false` + +Defines the initial direction of the count. + +```html {2} +
+ ... +
+``` diff --git a/packages/docs/components/Indexable/stories/counter/app.js b/packages/docs/components/Indexable/stories/counter/app.js new file mode 100644 index 00000000..aeb304a3 --- /dev/null +++ b/packages/docs/components/Indexable/stories/counter/app.js @@ -0,0 +1,36 @@ +import { Base, createApp } from '@studiometa/js-toolkit'; +import { Indexable } from '@studiometa/ui'; + +class Counter extends Indexable { + static config = { + name: 'Counter', + }; + + get length() { + return 10; + } + + onIndex() { + this.$el.textContent = this.currentIndex; + } +} + +class App extends Base { + static config = { + name: 'App', + refs: ['prevBtn', 'nextBtn'], + components: { + Counter, + }, + }; + + onPrevBtnClick() { + this.$children.Counter.forEach((instance) => instance.goPrev()); + } + + onNextBtnClick() { + this.$children.Counter.forEach((instance) => instance.goNext()); + } +} + +export default createApp(App); diff --git a/packages/docs/components/Indexable/stories/counter/app.twig b/packages/docs/components/Indexable/stories/counter/app.twig new file mode 100644 index 00000000..2d10004d --- /dev/null +++ b/packages/docs/components/Indexable/stories/counter/app.twig @@ -0,0 +1,11 @@ +Normal: + 0 +
+Infinite: + 0 +
+Alternate: + 0 +
+ + diff --git a/packages/tests/Indexable/Indexable.spec.ts b/packages/tests/Indexable/Indexable.spec.ts new file mode 100644 index 00000000..d435b4ee --- /dev/null +++ b/packages/tests/Indexable/Indexable.spec.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Indexable } from '@studiometa/ui'; +import { h } from '#test-utils'; + +class TestIndexable extends Indexable { + #length = 3; + + get length() { + return this.#length; + } + + set length(value: number) { + this.#length = value; + } +} + +describe('The Indexable class', () => { + let indexable: TestIndexable; + let element: HTMLElement; + + beforeEach(() => { + element = h('div'); + indexable = new TestIndexable(element); + }); + + describe(`"${Indexable.MODES.NORMAL}" mode`, () => { + beforeEach(() => { + indexable.mode = Indexable.MODES.NORMAL; + }); + + it('should stay in bounds indexes', () => { + indexable.currentIndex = 0; + expect(indexable.nextIndex).toBe(1); + expect(indexable.prevIndex).toBe(0); + + indexable.currentIndex = 2; + expect(indexable.nextIndex).toBe(2); + expect(indexable.prevIndex).toBe(1); + }); + }); + + describe(`"${Indexable.MODES.INFINITE}" mode`, () => { + beforeEach(() => { + indexable.mode = Indexable.MODES.INFINITE; + }); + + it('should wrap around indexes', () => { + indexable.currentIndex = 0; + expect(indexable.nextIndex).toBe(1); + expect(indexable.prevIndex).toBe(2); // (0 - 1 + 3) % 3 = 2 + + indexable.currentIndex = 2; + expect(indexable.nextIndex).toBe(0); // (2 + 1) % 3 = 0 + expect(indexable.prevIndex).toBe(1); + }); + + it('should handle out of bounds indexes', () => { + indexable.currentIndex = -1; + expect(indexable.currentIndex).toBe(2); // (-1 + 3) % 3 = 2 + + indexable.currentIndex = 5; + expect(indexable.currentIndex).toBe(2); // (5) % 3 = 2 + }); + }); + + describe(`"${Indexable.MODES.ALTERNATE}" mode`, () => { + beforeEach(() => { + indexable.mode = Indexable.MODES.ALTERNATE; + }); + + it('should alternate direction when reaching bounds', () => { + indexable.currentIndex = 0; + expect(indexable.nextIndex).toBe(1); + expect(indexable.prevIndex).toBe(1); + + indexable.currentIndex = 2; + expect(indexable.nextIndex).toBe(1); + expect(indexable.prevIndex).toBe(1); + }); + + it('should handle out of bounds indexes', () => { + indexable.currentIndex = -1; + expect(indexable.currentIndex).toBe(1); + + indexable.currentIndex = 5; + expect(indexable.currentIndex).toBe(1); + }); + }); + + describe('goTo method', () => { + it('should go to specific index', async () => { + const emitSpy = vi.spyOn(indexable, '$emit'); + + await indexable.goTo(1); + expect(indexable.currentIndex).toBe(1); + expect(emitSpy).toHaveBeenCalledWith('index', 1); + }); + + it(`should handle "${Indexable.INSTRUCTIONS.NEXT}" instruction`, async () => { + await indexable.goTo(Indexable.INSTRUCTIONS.NEXT); + expect(indexable.currentIndex).toBe(1); + }); + + it(`should handle "${Indexable.INSTRUCTIONS.PREVIOUS}" instruction`, async () => { + indexable.currentIndex = 1; + await indexable.goTo(Indexable.INSTRUCTIONS.PREVIOUS); + expect(indexable.currentIndex).toBe(0); + }); + + it(`should handle "${Indexable.INSTRUCTIONS.FIRST}" instruction`, async () => { + indexable.currentIndex = 2; + await indexable.goTo(Indexable.INSTRUCTIONS.FIRST); + expect(indexable.currentIndex).toBe(0); + }); + + it(`should handle "${Indexable.INSTRUCTIONS.LAST}" instruction`, async () => { + await indexable.goTo(Indexable.INSTRUCTIONS.LAST); + expect(indexable.currentIndex).toBe(2); + }); + + it(`should handle "${Indexable.INSTRUCTIONS.RANDOM}" instruction`, async () => { + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.5); + + await indexable.goTo(Indexable.INSTRUCTIONS.RANDOM); + expect(indexable.currentIndex).toBeGreaterThanOrEqual(0); + expect(indexable.currentIndex).toBeLessThanOrEqual(2); + + randomSpy.mockRestore(); + }); + + it('should handle reverse with instructions', async () => { + indexable.isReverse = true; + + indexable.currentIndex = 1; + await indexable.goTo(Indexable.INSTRUCTIONS.PREVIOUS); + expect(indexable.currentIndex).toBe(2); + + indexable.currentIndex = 1; + await indexable.goTo(Indexable.INSTRUCTIONS.NEXT); + expect(indexable.currentIndex).toBe(0); + + await indexable.goTo(Indexable.INSTRUCTIONS.FIRST); + expect(indexable.currentIndex).toBe(2); + + await indexable.goTo(Indexable.INSTRUCTIONS.LAST); + expect(indexable.currentIndex).toBe(0); + }); + + it('should reject invalid instruction', async () => { + const warnSpy = vi.spyOn(indexable, '$warn'); + + await expect(indexable.goTo('invalid' as any)).rejects.toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith('Invalid goto instruction.'); + expect(indexable.currentIndex).toBe(0); + }); + }); +}); diff --git a/packages/tests/index.spec.ts b/packages/tests/index.spec.ts index 2f426fb0..2e0dc86b 100644 --- a/packages/tests/index.spec.ts +++ b/packages/tests/index.spec.ts @@ -35,6 +35,7 @@ test('components exports', () => { "FrameTarget", "FrameTriggerLoader", "Hoverable", + "Indexable", "LargeText", "LazyInclude", "Menu", @@ -65,6 +66,7 @@ test('components exports', () => { "Transition", "animationScrollWithEase", "withDeprecation", + "withIndex", "withTransition", ] `); diff --git a/packages/ui/Indexable/Indexable.ts b/packages/ui/Indexable/Indexable.ts new file mode 100644 index 00000000..d4abb865 --- /dev/null +++ b/packages/ui/Indexable/Indexable.ts @@ -0,0 +1,14 @@ +import { Base, BaseConfig, BaseProps } from '@studiometa/js-toolkit'; +import { withIndex } from '../decorators/index.js'; + +/** + * Indexable class. + */ +export class Indexable extends withIndex(Base) { + /** + * Config. + */ + static config: BaseConfig = { + name: 'Indexable', + }; +} diff --git a/packages/ui/Indexable/index.ts b/packages/ui/Indexable/index.ts new file mode 100644 index 00000000..d7c1acbf --- /dev/null +++ b/packages/ui/Indexable/index.ts @@ -0,0 +1 @@ +export * from './Indexable.js'; diff --git a/packages/ui/decorators/index.ts b/packages/ui/decorators/index.ts index c1c2cc82..47cfa628 100644 --- a/packages/ui/decorators/index.ts +++ b/packages/ui/decorators/index.ts @@ -1,2 +1,3 @@ export * from './withTransition.js'; export * from './withDeprecation.js'; +export * from './withIndex.js'; diff --git a/packages/ui/decorators/withIndex.ts b/packages/ui/decorators/withIndex.ts new file mode 100644 index 00000000..85b03a22 --- /dev/null +++ b/packages/ui/decorators/withIndex.ts @@ -0,0 +1,264 @@ +import type { + Base, + BaseDecorator, + BaseProps, + BaseConfig, + BaseInterface, +} from '@studiometa/js-toolkit'; +import { clamp, isString, randomInt } from '@studiometa/js-toolkit/utils'; + +const INDEXABLE_MODES = { + NORMAL: 'normal', + INFINITE: 'infinite', + ALTERNATE: 'alternate', +} as const; + +export type IndexableMode = typeof INDEXABLE_MODES[keyof typeof INDEXABLE_MODES]; + +const INDEXABLE_INSTRUCTIONS = { + NEXT: 'next', + PREVIOUS: 'previous', + FIRST: 'first', + LAST: 'last', + RANDOM: 'random', +} as const; + +export type IndexableInstructions = typeof INDEXABLE_INSTRUCTIONS[keyof typeof INDEXABLE_INSTRUCTIONS]; + +export interface IndexableProps extends BaseProps { + $options: { + mode: IndexableMode; + reverse: boolean; + }; +} + +export interface IndexableInterface extends BaseInterface { + /** + * Index storage. + */ + __index: number; + + /** + * Is reverse ? + */ + get isReverse(): boolean; + set isReverse(value: boolean); + + /** + * Get mode. + */ + get mode(): IndexableMode; + set mode(value: IndexableMode); + + /** + * Get the length. + */ + get length(): number; + + /** + * Get the minimum index. + */ + get minIndex(): number; + + /** + * Get the maximum index. + */ + get maxIndex(): number; + + /** + * Get the current index. + */ + get currentIndex(): number; + set currentIndex(value: number); + + /** + * Get the first index. + */ + get firstIndex(): number; + + /** + * Get the last index. + */ + get lastIndex(): number; + + /** + * Get the previous index. + */ + get prevIndex(): number; + + /** + * Get the next index. + */ + get nextIndex(): number; + + /** + * Go to the specified index or instruction. + */ + goTo(indexOrInstruction?: number | IndexableInstructions): Promise; + + /** + * Go to the next index. + */ + goNext(): Promise; + + /** + * Go to the previous index. + */ + goPrev(): Promise; +} + +/** + * Extend a class to add index management. + */ +export function withIndex( + BaseClass: typeof Base, +): BaseDecorator & { + MODES: typeof INDEXABLE_MODES; + INSTRUCTIONS: typeof INDEXABLE_INSTRUCTIONS; +} { + /** + * Class. + */ + class Indexable extends BaseClass { + /** + * Config. + */ + static config: BaseConfig = { + ...BaseClass.config, + emits: ['index'], + options: { + mode: { + type: String, + default: INDEXABLE_MODES.NORMAL, + }, + reverse: Boolean, + }, + }; + + __index = 0; + + get isReverse() { + return this.$options.reverse === true; + } + + set isReverse(value) { + this.$options.reverse = !!value; + } + + get mode() { + const { mode } = this.$options; + + if (!Object.values(INDEXABLE_MODES).includes(mode)) { + return INDEXABLE_MODES.NORMAL; + } + + return mode; + } + + set mode(value) { + this.$options.mode = Object.values(INDEXABLE_MODES).includes(value) ? value : INDEXABLE_MODES.NORMAL; + } + + get length() { + this.$warn('The length property should be overridden to match with the actual number of items. Finite length is required for infinite and alternate modes.'); + return Number.POSITIVE_INFINITY; + } + + get minIndex() { + return 0; + } + + get maxIndex() { + return this.length - 1; + } + + get currentIndex() { + return this.__index; + } + + set currentIndex(value) { + switch (this.mode) { + case INDEXABLE_MODES.ALTERNATE: + if (Math.floor(value/this.length) % 2 !== 0) { + this.isReverse = !this.isReverse; + } + const cycleLength = this.length * 2; + const cycleIndex = Math.abs(value) % cycleLength; + this.__index = Math.min(cycleIndex, cycleLength - cycleIndex); + break; + case INDEXABLE_MODES.INFINITE: + this.__index = (value + this.length) % this.length + break; + case INDEXABLE_MODES.NORMAL: + default: + this.__index = clamp(value, this.minIndex, this.maxIndex); + break; + } + this.$emit('index', this.currentIndex); + } + + get firstIndex() { + return this.isReverse ? this.maxIndex : this.minIndex; + } + + get lastIndex() { + return this.isReverse ? this.minIndex : this.maxIndex; + } + + get prevIndex() { + let rawIndex = this.isReverse ? this.currentIndex + 1 : this.currentIndex - 1; + if (this.mode === INDEXABLE_MODES.ALTERNATE && (rawIndex > this.maxIndex || rawIndex < this.minIndex)) { + this.isReverse = !this.isReverse; + rawIndex = this.isReverse ? this.currentIndex + 1 : this.currentIndex - 1; + } + return this.mode === INDEXABLE_MODES.NORMAL ? clamp(rawIndex, this.minIndex, this.maxIndex) : (rawIndex + this.length) % this.length; + } + + get nextIndex() { + let rawIndex = this.isReverse ? this.currentIndex - 1 : this.currentIndex + 1; + if (this.mode === INDEXABLE_MODES.ALTERNATE && (rawIndex > this.maxIndex || rawIndex < this.minIndex)) { + this.isReverse = !this.isReverse; + rawIndex = this.isReverse ? this.currentIndex - 1 : this.currentIndex + 1; + } + return this.mode === INDEXABLE_MODES.NORMAL ? clamp(rawIndex, this.minIndex, this.maxIndex) : (rawIndex + this.length) % this.length; + } + + goTo(indexOrInstruction) { + if (isString(indexOrInstruction)) { + switch (indexOrInstruction) { + case INDEXABLE_INSTRUCTIONS.NEXT: + return this.goTo(this.nextIndex); + case INDEXABLE_INSTRUCTIONS.PREVIOUS: + return this.goTo(this.prevIndex); + case INDEXABLE_INSTRUCTIONS.FIRST: + return this.goTo(this.firstIndex); + case INDEXABLE_INSTRUCTIONS.LAST: + return this.goTo(this.lastIndex); + case INDEXABLE_INSTRUCTIONS.RANDOM: + // @TODO: eventually store previous indexes to avoid duplicates + return this.goTo(randomInt(this.minIndex, this.maxIndex)); + default: + this.$warn('Invalid goto instruction.'); + return Promise.reject(); + } + } + this.currentIndex = indexOrInstruction; + return Promise.resolve(); + } + + goNext() { + return this.goTo(this.nextIndex); + } + + goPrev() { + return this.goTo(this.prevIndex); + } + } + + // Add constants as static properties to the returned class + const IndexableWithConstants = Indexable as any; + IndexableWithConstants.MODES = INDEXABLE_MODES; + IndexableWithConstants.INSTRUCTIONS = INDEXABLE_INSTRUCTIONS; + + return IndexableWithConstants; +} diff --git a/packages/ui/index.ts b/packages/ui/index.ts index c377ee75..3b2b7e90 100644 --- a/packages/ui/index.ts +++ b/packages/ui/index.ts @@ -11,6 +11,7 @@ export * from './Fetch/index.js'; export * from './Figure/index.js'; export * from './FigureVideo/index.js'; export * from './Frame/index.js'; +export * from './Indexable/index.js'; export * from './Hoverable/index.js'; export * from './LargeText/index.js'; export * from './LazyInclude/index.js';