Compare commits
4
Commits
8e879311ec
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e273cad04b | ||
|
|
ba7aa0664a | ||
|
|
37174179bd | ||
|
|
f1ce8ea26e |
@@ -58,6 +58,7 @@ jobs:
|
|||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
run: yarn test:e2e
|
run: yarn test:e2e
|
||||||
|
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs: [verify, e2e]
|
needs: [verify, e2e]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -66,18 +67,32 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with: { node-version: '25' }
|
with: { node-version: '25' }
|
||||||
|
|
||||||
|
- name: Check token is present
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.CAROUSEL_PACKAGE }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$NODE_AUTH_TOKEN" ]; then
|
||||||
|
echo "NODE_AUTH_TOKEN is EMPTY — secret not injected"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "NODE_AUTH_TOKEN length: ${#NODE_AUTH_TOKEN}"
|
||||||
|
|
||||||
- name: Enable Corepack
|
- name: Enable Corepack
|
||||||
run: |
|
run: |
|
||||||
corepack enable
|
corepack enable
|
||||||
corepack prepare yarn@4.11.0 --activate
|
corepack prepare yarn@4.11.0 --activate
|
||||||
- run: yarn install --immutable
|
- run: yarn install --immutable
|
||||||
|
|
||||||
- name: Assert tag matches package.json version
|
- name: Assert tag matches package.json version
|
||||||
run: |
|
run: |
|
||||||
TAG="${GITHUB_REF_NAME#v}"
|
TAG="${GITHUB_REF_NAME#v}"
|
||||||
PKG="$(node -p "require('./package.json').version")"
|
PKG="$(node -p "require('./package.json').version")"
|
||||||
test "$TAG" = "$PKG" || { echo "tag v$TAG != package.json $PKG"; exit 1; }
|
test "$TAG" = "$PKG" || { echo "tag v$TAG != package.json $PKG"; exit 1; }
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: yarn build
|
run: yarn build
|
||||||
|
|
||||||
- name: Publish to Gitea registry
|
- name: Publish to Gitea registry
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.CAROUSEL_PACKAGE }}
|
NODE_AUTH_TOKEN: ${{ secrets.CAROUSEL_PACKAGE }}
|
||||||
|
|||||||
@@ -1 +1,7 @@
|
|||||||
nodeLinker: node-modules
|
nodeLinker: node-modules
|
||||||
|
|
||||||
|
npmScopes:
|
||||||
|
ilia:
|
||||||
|
npmPublishRegistry: "https://git.allmy.work/api/packages/ilia/npm/"
|
||||||
|
npmAlwaysAuth: true
|
||||||
|
npmAuthToken: "${NODE_AUTH_TOKEN:-}"
|
||||||
@@ -5,6 +5,11 @@ at each `v*` release tag.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-07-02
|
||||||
|
|
||||||
|
- Core: `atStart`/`atEnd`/`overflow` getters; `change` now fires on any state change (index, count, overflow), consumers re-read getters.
|
||||||
|
- Core: ResizeObserver drives `overflow`; MutationObserver observes/unobserves slides so count/index/edge flags stay correct across add/remove without recreating the carousel.
|
||||||
|
|
||||||
## [0.1.0] - 2026-07-01
|
## [0.1.0] - 2026-07-01
|
||||||
|
|
||||||
- Initial core (`createCarousel`), `dots()` fallback, `autoplay()`, and `carousel.css`.
|
- Initial core (`createCarousel`), `dots()` fallback, `autoplay()`, and `carousel.css`.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ilia/carousel",
|
"name": "@ilia/carousel",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
"*.css"
|
"*.css"
|
||||||
|
|||||||
+50
-1
@@ -6,6 +6,9 @@ export type Carousel = {
|
|||||||
scrollToIndex(i: number): void;
|
scrollToIndex(i: number): void;
|
||||||
readonly index: number;
|
readonly index: number;
|
||||||
readonly count: number;
|
readonly count: number;
|
||||||
|
readonly atStart: boolean;
|
||||||
|
readonly atEnd: boolean;
|
||||||
|
readonly overflow: boolean;
|
||||||
on(evt: CarouselEvent, cb: (index: number) => void): () => void;
|
on(evt: CarouselEvent, cb: (index: number) => void): () => void;
|
||||||
destroy(): void;
|
destroy(): void;
|
||||||
};
|
};
|
||||||
@@ -18,6 +21,11 @@ export function createCarousel(track: HTMLElement): Carousel {
|
|||||||
const slides = () => Array.from(track.children) as HTMLElement[];
|
const slides = () => Array.from(track.children) as HTMLElement[];
|
||||||
let index = 0;
|
let index = 0;
|
||||||
const listeners = new Set<(i: number) => void>();
|
const listeners = new Set<(i: number) => void>();
|
||||||
|
// One 'change' event for any state change (index/count/overflow); consumers
|
||||||
|
// re-read the getters. Keeps the surface at a single event type.
|
||||||
|
const notify = () => {
|
||||||
|
for (const cb of listeners) cb(index);
|
||||||
|
};
|
||||||
|
|
||||||
const clamp = (i: number) => Math.max(0, Math.min(i, slides().length - 1));
|
const clamp = (i: number) => Math.max(0, Math.min(i, slides().length - 1));
|
||||||
|
|
||||||
@@ -43,13 +51,43 @@ export function createCarousel(track: HTMLElement): Carousel {
|
|||||||
const i = slides().indexOf(best.target as HTMLElement);
|
const i = slides().indexOf(best.target as HTMLElement);
|
||||||
if (i !== -1 && i !== index) {
|
if (i !== -1 && i !== index) {
|
||||||
index = i;
|
index = i;
|
||||||
for (const cb of listeners) cb(index);
|
notify();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ root: track, threshold: 0.6 },
|
{ root: track, threshold: 0.6 },
|
||||||
);
|
);
|
||||||
for (const s of slides()) io.observe(s);
|
for (const s of slides()) io.observe(s);
|
||||||
|
|
||||||
|
// overflow: whether the track actually scrolls. Changes on resize (track box)
|
||||||
|
// *and* on add/remove (scrollWidth), so both observers recompute it.
|
||||||
|
let overflow = false;
|
||||||
|
const syncOverflow = () => {
|
||||||
|
const o = track.scrollWidth > track.clientWidth;
|
||||||
|
if (o !== overflow) {
|
||||||
|
overflow = o;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// observe/unobserve slides as they're added/removed so count, index and edge
|
||||||
|
// flags stay correct without recreating the carousel.
|
||||||
|
const mo = new MutationObserver((records) => {
|
||||||
|
for (const r of records) {
|
||||||
|
for (const n of r.addedNodes) if (n instanceof HTMLElement) io.observe(n);
|
||||||
|
for (const n of r.removedNodes) if (n instanceof HTMLElement) io.unobserve(n);
|
||||||
|
}
|
||||||
|
index = clamp(index);
|
||||||
|
syncOverflow();
|
||||||
|
notify();
|
||||||
|
});
|
||||||
|
mo.observe(track, { childList: true });
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
if (syncOverflow()) notify();
|
||||||
|
});
|
||||||
|
ro.observe(track);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
next: () => scrollToIndex(index + 1),
|
next: () => scrollToIndex(index + 1),
|
||||||
prev: () => scrollToIndex(index - 1),
|
prev: () => scrollToIndex(index - 1),
|
||||||
@@ -60,12 +98,23 @@ export function createCarousel(track: HTMLElement): Carousel {
|
|||||||
get count() {
|
get count() {
|
||||||
return slides().length;
|
return slides().length;
|
||||||
},
|
},
|
||||||
|
get atStart() {
|
||||||
|
return index === 0;
|
||||||
|
},
|
||||||
|
get atEnd() {
|
||||||
|
return index >= slides().length - 1;
|
||||||
|
},
|
||||||
|
get overflow() {
|
||||||
|
return overflow;
|
||||||
|
},
|
||||||
on(_evt, cb) {
|
on(_evt, cb) {
|
||||||
listeners.add(cb);
|
listeners.add(cb);
|
||||||
return () => listeners.delete(cb);
|
return () => listeners.delete(cb);
|
||||||
},
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
io.disconnect();
|
io.disconnect();
|
||||||
|
mo.disconnect();
|
||||||
|
ro.disconnect();
|
||||||
listeners.clear();
|
listeners.clear();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,6 +29,23 @@ class IOShim {
|
|||||||
}
|
}
|
||||||
vi.stubGlobal('IntersectionObserver', IOShim);
|
vi.stubGlobal('IntersectionObserver', IOShim);
|
||||||
|
|
||||||
|
// jsdom has no ResizeObserver; record instance so tests can trigger it.
|
||||||
|
class ROShim {
|
||||||
|
static last: ROShim | null = null;
|
||||||
|
cb: ResizeObserverCallback;
|
||||||
|
constructor(cb: ResizeObserverCallback) {
|
||||||
|
this.cb = cb;
|
||||||
|
ROShim.last = this;
|
||||||
|
}
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
fire() {
|
||||||
|
this.cb([] as unknown as ResizeObserverEntry[], this as unknown as ResizeObserver);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vi.stubGlobal('ResizeObserver', ROShim);
|
||||||
|
|
||||||
function makeTrack(n: number): HTMLElement {
|
function makeTrack(n: number): HTMLElement {
|
||||||
const track = document.createElement('div');
|
const track = document.createElement('div');
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
@@ -85,3 +102,76 @@ test('unsubscribe stops delivery; destroy disconnects observer', () => {
|
|||||||
c.destroy();
|
c.destroy();
|
||||||
expect(spy).toHaveBeenCalled();
|
expect(spy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('atStart/atEnd track the current index', () => {
|
||||||
|
const c = createCarousel(track);
|
||||||
|
expect(c.atStart).toBe(true);
|
||||||
|
expect(c.atEnd).toBe(false);
|
||||||
|
(IOShim.last as IOShim).emit(2);
|
||||||
|
expect(c.atStart).toBe(false);
|
||||||
|
expect(c.atEnd).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overflow flips on resize and fires change once per change', () => {
|
||||||
|
const c = createCarousel(track);
|
||||||
|
expect(c.overflow).toBe(false);
|
||||||
|
const seen: number[] = [];
|
||||||
|
c.on('change', (i) => seen.push(i));
|
||||||
|
|
||||||
|
Object.defineProperty(track, 'scrollWidth', { value: 100, configurable: true });
|
||||||
|
Object.defineProperty(track, 'clientWidth', { value: 50, configurable: true });
|
||||||
|
(ROShim.last as ROShim).fire();
|
||||||
|
expect(c.overflow).toBe(true);
|
||||||
|
expect(seen).toEqual([0]);
|
||||||
|
|
||||||
|
(ROShim.last as ROShim).fire(); // no change → no re-fire
|
||||||
|
expect(seen).toEqual([0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adding/removing slides updates count and fires change', async () => {
|
||||||
|
const c = createCarousel(track);
|
||||||
|
const io = IOShim.last as IOShim;
|
||||||
|
const seen: number[] = [];
|
||||||
|
c.on('change', (i) => seen.push(i));
|
||||||
|
|
||||||
|
const observe = vi.spyOn(io, 'observe');
|
||||||
|
const unobserve = vi.spyOn(io, 'unobserve');
|
||||||
|
|
||||||
|
const extra = document.createElement('div');
|
||||||
|
track.append(extra);
|
||||||
|
await Promise.resolve(); // MutationObserver is async (microtask)
|
||||||
|
expect(observe).toHaveBeenCalledWith(extra);
|
||||||
|
|
||||||
|
track.removeChild(extra);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(unobserve).toHaveBeenCalledWith(extra);
|
||||||
|
expect(c.count).toBe(3);
|
||||||
|
expect(seen.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overflow recomputes on add/remove without a resize', async () => {
|
||||||
|
Object.defineProperty(track, 'clientWidth', { value: 50, configurable: true });
|
||||||
|
Object.defineProperty(track, 'scrollWidth', { value: 40, configurable: true });
|
||||||
|
const c = createCarousel(track);
|
||||||
|
expect(c.overflow).toBe(false);
|
||||||
|
const seen: number[] = [];
|
||||||
|
c.on('change', (i) => seen.push(i));
|
||||||
|
|
||||||
|
// content now exceeds the track — MutationObserver must pick it up, no RO fire
|
||||||
|
Object.defineProperty(track, 'scrollWidth', { value: 120, configurable: true });
|
||||||
|
track.append(document.createElement('div'));
|
||||||
|
await Promise.resolve(); // MutationObserver is async (microtask)
|
||||||
|
expect(c.overflow).toBe(true);
|
||||||
|
expect(seen.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing the current slide clamps index into range', async () => {
|
||||||
|
const c = createCarousel(track);
|
||||||
|
(IOShim.last as IOShim).emit(2);
|
||||||
|
expect(c.index).toBe(2);
|
||||||
|
track.removeChild(track.children[2]);
|
||||||
|
track.removeChild(track.children[1]);
|
||||||
|
await Promise.resolve(); // MutationObserver is async (microtask)
|
||||||
|
expect(c.index).toBe(0);
|
||||||
|
expect(c.atEnd).toBe(true);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user