Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions packages/docs/components/Indexable/examples.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
title: Indexable examples
---

# Examples

## Counter

<llm-exclude>
<PreviewPlayground
:html="() => import('./stories/counter/app.twig')"
:script="() => import('./stories/counter/app.js?raw')"
/>
</llm-exclude>
<llm-only>

:::code-group

<<< ./stories/toggle/app.twig
<<< ./stories/toggle/app.js

:::

</llm-only>
77 changes: 77 additions & 0 deletions packages/docs/components/Indexable/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
badges: [JS]
---

# Indexable <Badges :texts="$frontmatter.badges" />

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
<output data-component="Counter" data-option-mode="infinite">
0
</output>
<button type="button" data-ref="prevBtn">Previous</button>
<button type="button" data-ref="nextBtn">Next</button>
```

::: tip Example
Checkout the [result of this example](./examples#counter) for a better understanding.
:::
38 changes: 38 additions & 0 deletions packages/docs/components/Indexable/js-api.md
Original file line number Diff line number Diff line change
@@ -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}
<div data-component="Indexable" data-option-mode="infinite">
...
</div>
```

### `reverse`

- Type: `Boolean`
- Default: `false`

Defines the initial direction of the count.

```html {2}
<div data-component="Indexable" data-option-reverse>
...
</div>
```
36 changes: 36 additions & 0 deletions packages/docs/components/Indexable/stories/counter/app.js
Original file line number Diff line number Diff line change
@@ -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);
11 changes: 11 additions & 0 deletions packages/docs/components/Indexable/stories/counter/app.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Normal: <output data-component="Counter" data-option-mode="normal">
0
</output><br>
Infinite: <output data-component="Counter" data-option-mode="infinite">
0
</output><br>
Alternate: <output data-component="Counter" data-option-mode="alternate">
0
</output><br>
<button type="button" data-ref="prevBtn">Previous</button>
<button type="button" data-ref="nextBtn">Next</button>
157 changes: 157 additions & 0 deletions packages/tests/Indexable/Indexable.spec.ts
Original file line number Diff line number Diff line change
@@ -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');

Check failure on line 150 in packages/tests/Indexable/Indexable.spec.ts

View workflow job for this annotation

GitHub Actions / unit_node

tests/Indexable/Indexable.spec.ts > The Indexable class > goTo method > should reject invalid instruction

TypeError: Cannot read properties of undefined (reading '$options') ❯ get $warn ../node_modules/@studiometa/js-toolkit/Base/Base.js:137:17 ❯ tests/Indexable/Indexable.spec.ts:150:26

Check failure on line 150 in packages/tests/Indexable/Indexable.spec.ts

View workflow job for this annotation

GitHub Actions / unit_node

tests/Indexable/Indexable.spec.ts > The Indexable class > goTo method > should reject invalid instruction

TypeError: Cannot read properties of undefined (reading '$options') ❯ get $warn ../node_modules/@studiometa/js-toolkit/Base/Base.js:137:17 ❯ tests/Indexable/Indexable.spec.ts:150:26

Check failure on line 150 in packages/tests/Indexable/Indexable.spec.ts

View workflow job for this annotation

GitHub Actions / unit_node

tests/Indexable/Indexable.spec.ts > The Indexable class > goTo method > should reject invalid instruction

TypeError: Cannot read properties of undefined (reading '$options') ❯ get $warn ../node_modules/@studiometa/js-toolkit/Base/Base.js:137:17 ❯ tests/Indexable/Indexable.spec.ts:150:26

Check failure on line 150 in packages/tests/Indexable/Indexable.spec.ts

View workflow job for this annotation

GitHub Actions / unit_node

tests/Indexable/Indexable.spec.ts > The Indexable class > goTo method > should reject invalid instruction

TypeError: Cannot read properties of undefined (reading '$options') ❯ get $warn ../node_modules/@studiometa/js-toolkit/Base/Base.js:137:17 ❯ tests/Indexable/Indexable.spec.ts:150:26

await expect(indexable.goTo('invalid' as any)).rejects.toBeUndefined();
expect(warnSpy).toHaveBeenCalledWith('Invalid goto instruction.');
expect(indexable.currentIndex).toBe(0);
});
});
});
2 changes: 2 additions & 0 deletions packages/tests/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ test('components exports', () => {
"FrameTarget",
"FrameTriggerLoader",
"Hoverable",
"Indexable",
"LargeText",
"LazyInclude",
"Menu",
Expand Down Expand Up @@ -65,6 +66,7 @@ test('components exports', () => {
"Transition",
"animationScrollWithEase",
"withDeprecation",
"withIndex",
"withTransition",
]
`);
Expand Down
14 changes: 14 additions & 0 deletions packages/ui/Indexable/Indexable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Base, BaseConfig, BaseProps } from '@studiometa/js-toolkit';
import { withIndex } from '../decorators/index.js';

/**
* Indexable class.
*/
export class Indexable<T extends BaseProps = BaseProps> extends withIndex<Base>(Base)<T> {
/**
* Config.
*/
static config: BaseConfig = {
name: 'Indexable',
};
}
1 change: 1 addition & 0 deletions packages/ui/Indexable/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Indexable.js';
1 change: 1 addition & 0 deletions packages/ui/decorators/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './withTransition.js';
export * from './withDeprecation.js';
export * from './withIndex.js';
Loading
Loading