Compare commits

13 Commits
Author SHA1 Message Date
ilia a234dc4d98 Merge pull request 'feat: edge flags, overflow, and add/remove-aware observers' (#4) from feat/edge-flags-observers into main
CI / verify (push) Successful in 26s
CI / e2e (push) Successful in 26s
CI / publish (push) Has been skipped
Reviewed-on: #4
2026-07-02 08:41:08 +00:00
Ilia Mashkov e273cad04b feat: edge flags, overflow, and add/remove-aware observers
CI / verify (push) Successful in 26s
CI / e2e (push) Successful in 26s
CI / verify (pull_request) Successful in 29s
CI / publish (push) Successful in 18s
CI / e2e (pull_request) Successful in 29s
CI / publish (pull_request) Has been skipped
2026-07-02 11:26:45 +03:00
Ilia Mashkov ba7aa0664a fix: default NODE_AUTH_TOKEN to empty so install works without it
CI / publish (push) Successful in 18s
CI / e2e (push) Successful in 25s
CI / verify (push) Successful in 26s
2026-07-02 10:23:22 +03:00
Ilia Mashkov 37174179bd ci: add logs
CI / verify (push) Failing after 10s
CI / e2e (push) Has been skipped
CI / publish (push) Has been skipped
2026-07-02 10:19:44 +03:00
Ilia Mashkov f1ce8ea26e chore: add registry settings
CI / e2e (push) Has been skipped
CI / publish (push) Has been skipped
CI / verify (push) Failing after 10s
2026-07-02 10:08:27 +03:00
ilia 8e879311ec Merge pull request 'Fix/ci token' (#3) from fix/ci-token into main
CI / verify (push) Successful in 27s
CI / e2e (push) Successful in 25s
CI / publish (push) Failing after 18s
Reviewed-on: #3
2026-07-02 06:31:38 +00:00
Ilia Mashkov 2e0bc6d17e fix: change publish command
CI / verify (pull_request) Successful in 31s
CI / e2e (pull_request) Successful in 30s
CI / publish (pull_request) Has been skipped
2026-07-02 09:29:55 +03:00
Ilia Mashkov 5277e9c55c fix: change token name 2026-07-02 09:28:39 +03:00
ilia 6967d722ed Merge pull request 'Chore/release v0.1.0' (#2) from chore/release-v0.1.0 into main
CI / verify (push) Successful in 27s
CI / e2e (push) Successful in 25s
CI / publish (push) Failing after 20s
Reviewed-on: #2
2026-07-01 07:52:21 +00:00
Ilia Mashkov 7920fb73f3 chore: release v0.1.0
CI / verify (pull_request) Successful in 30s
CI / e2e (pull_request) Successful in 29s
CI / publish (pull_request) Has been skipped
2026-07-01 10:42:01 +03:00
Ilia Mashkov 611a13f088 chore: release v0.1.0 2026-07-01 09:38:51 +03:00
ilia a784271aa8 Merge pull request 'Feat/carousel impl' (#1) from feat/carousel-impl into main
CI / verify (push) Successful in 27s
CI / e2e (push) Successful in 25s
CI / publish (push) Has been skipped
Reviewed-on: #1
2026-07-01 06:30:08 +00:00
Ilia Mashkov 6b79e1120b ci: pin playwright to 1.61.1 matching runner image
CI / verify (pull_request) Successful in 29s
CI / e2e (pull_request) Successful in 1m20s
CI / publish (pull_request) Has been skipped
Floating "^1" resolved to 1.61.1 while the e2e container was pinned
to v1.59.0-jammy, so its browser builds were missing at launch. Pin the
dep exact and bump the image so the two versions move together.
2026-07-01 09:17:42 +03:00
8 changed files with 184 additions and 8 deletions
+18 -3
View File
@@ -39,7 +39,7 @@ jobs:
needs: verify needs: verify
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/playwright:v1.59.0-jammy image: mcr.microsoft.com/playwright:v1.61.1-jammy
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Enable Corepack - name: Enable Corepack
@@ -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,19 +67,33 @@ 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.CI_DEPLOY_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.CAROUSEL_PACKAGE }}
run: npm publish run: yarn npm publish
+1
View File
@@ -13,3 +13,4 @@ logs_llm/
*.md *.md
!README.md !README.md
!CHANGELOG.md
+6
View File
@@ -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:-}"
+15
View File
@@ -0,0 +1,15 @@
# Changelog
Entries are derived from [Conventional Commits](https://www.conventionalcommits.org)
at each `v*` release tag.
## [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
- Initial core (`createCarousel`), `dots()` fallback, `autoplay()`, and `carousel.css`.
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ilia/carousel", "name": "@ilia/carousel",
"version": "0.0.0", "version": "0.2.0",
"type": "module", "type": "module",
"sideEffects": [ "sideEffects": [
"*.css" "*.css"
@@ -31,7 +31,7 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2", "@biomejs/biome": "^2",
"@playwright/test": "^1", "@playwright/test": "1.61.1",
"@size-limit/preset-small-lib": "^11", "@size-limit/preset-small-lib": "^11",
"@vitest/coverage-v8": "^2", "@vitest/coverage-v8": "^2",
"jsdom": "^25", "jsdom": "^25",
+50 -1
View File
@@ -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();
}, },
}; };
+90
View File
@@ -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);
});
+2 -2
View File
@@ -737,7 +737,7 @@ __metadata:
resolution: "@ilia/carousel@workspace:." resolution: "@ilia/carousel@workspace:."
dependencies: dependencies:
"@biomejs/biome": "npm:^2" "@biomejs/biome": "npm:^2"
"@playwright/test": "npm:^1" "@playwright/test": "npm:1.61.1"
"@size-limit/preset-small-lib": "npm:^11" "@size-limit/preset-small-lib": "npm:^11"
"@vitest/coverage-v8": "npm:^2" "@vitest/coverage-v8": "npm:^2"
jsdom: "npm:^25" jsdom: "npm:^25"
@@ -820,7 +820,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@playwright/test@npm:^1": "@playwright/test@npm:1.61.1":
version: 1.61.1 version: 1.61.1
resolution: "@playwright/test@npm:1.61.1" resolution: "@playwright/test@npm:1.61.1"
dependencies: dependencies: