;
export default VueTinySlider;
================================================
FILE: src/index.js
================================================
import { h } from 'vue';
// `Node` is a browser DOM global; fall back to Object on the server so that
// defining the component doesn't throw during SSR module evaluation.
var NodeType = typeof Node !== 'undefined' ? Node : Object;
var VueTinySlider = {
eventsList: [
'indexChanged',
'transitionStart',
'transitionEnd',
'newBreakpointStart',
'newBreakpointEnd',
'touchStart',
'touchMove',
'touchEnd',
'dragStart',
'dragMove',
'dragEnd'
],
props: {
mode: {
type: [String],
default: 'carousel'
},
autoInit: {
type: [Boolean],
default: true
},
axis: {
type: [String],
validator: value => {
return value === 'horizontal' || value === 'vertical';
}
},
items: {
type: [String, Number],
default: 1
},
gutter: {
type: [String, Number],
default: 0
},
edgePadding: {
type: [String, Number],
default: 0
},
fixedWidth: {
type: [String, Boolean, Number],
default: false
},
viewportMax: {
type: [String, Boolean, Number],
default: false
},
swipeAngle: {
type: [Boolean, Number],
default: 15
},
slideBy: {
type: [String, Number],
default: 1
},
controls: {
type: [String, Boolean],
default: true
},
controlsPosition: {
type: [String],
validator: value => {
return value === 'top' || value === 'bottom';
},
default: 'top'
},
controlsText: {
type: [Array],
default: () => ['prev', 'next']
},
controlsContainer: {
type: [Boolean, NodeType, String],
default: false
},
prevButton: {
type: [NodeType, String, Boolean],
default: false
},
nextButton: {
type: [NodeType, String, Boolean],
default: false
},
nav: {
type: [Boolean],
default: true
},
navPosition: {
type: [String],
default: "top"
},
navContainer: {
type: [Boolean, NodeType, String],
default: false
},
navAsThumbnails: {
type: [Boolean],
default: false
},
arrowKeys: {
type: [Boolean],
default: false
},
speed: {
type: [String, Number],
default: 300
},
autoplay: {
type: [Boolean],
default: false
},
autoplayTimeout: {
type: [Number],
default: 5000
},
autoplayDirection: {
type: [String],
default: 'forward',
validator: value => {
return value === 'forward' || value === 'backward';
}
},
autoplayText: {
type: [Array],
default: () => ['start', 'stop']
},
autoplayHoverPause: {
type: [Boolean],
default: false
},
autoplayButton: {
type: [Boolean, NodeType, String],
default: false,
},
autoplayButtonOutput: {
type: [Boolean],
default: true
},
autoplayResetOnVisibility: {
type: [Boolean],
default: true,
},
animateIn: {
type: [String],
default: 'tns-fadeIn'
},
animateOut: {
type: [String],
default: 'tns-fadeOut'
},
animateNormal: {
type: [String],
default: 'tns-normal'
},
animateDelay: {
type: [String, Number, Boolean],
default: false
},
loop: {
type: [Boolean],
default: true
},
rewind: {
type: [Boolean],
default: false
},
autoHeight: {
type: [Boolean],
default: false
},
responsive: {
type: [Boolean, Object],
default: false
},
lazyload: {
type: [Boolean],
default: false
},
touch: {
type: [Boolean],
default: true
},
mouseDrag: {
type: [Boolean],
default: false
},
nested: {
type: [String, Boolean],
default: false,
validator: value => {
return value === 'inner' || value === 'outer' || value === false;
}
},
freezable: {
type: [Boolean],
default: true
},
disable: {
type: [Boolean],
default: false
},
startIndex: {
type: [Number],
default: 0
},
onInit: {
type: [Function, Boolean],
default: false
},
center: {
type: Boolean,
default: false
},
lazyLoadSelector: {
type: String,
default: '.tns-lazy-img'
},
preventActionWhenRunning: {
type: Boolean,
default: false
},
autoWidth: {
type: Boolean,
default: false
},
preventScrollOnTouch: {
type: [String, Boolean],
default: false,
validator: value => {
return value === 'auto' || value === 'force' || value === false;
}
},
useLocalStorage: {
type: [Boolean],
default: true
}
},
mounted: function () {
if(this.autoInit) {
// init() is async (lazy-loads tiny-slider); swallow the returned
// promise — consumers who need ready-state should use @init event.
this.init();
}
},
beforeUnmount: function() {
if(this.slider) {
this.slider.destroy();
}
},
methods: {
$_vueTinySlider_subscribeTo (eventName) {
this.slider.events.on(eventName, (info) => {
this.$emit(eventName, info);
});
},
$_vueTinySlider_subscribeToAll () {
this.$options.eventsList.forEach(this.$_vueTinySlider_subscribeTo)
},
goTo: function(value) {
this.slider.goTo(value);
},
rebuild: function() {
this.slider = this.slider.rebuild();
this.$emit('rebuild');
},
getInfo: function() {
this.$emit('getInfo', this.slider.getInfo(), this.slider);
},
destroy: function() {
this.slider.destroy();
},
init: async function() {
// Lazy-import tiny-slider so the module's top-level `document` /
// `window` access never runs during SSR (mounted only fires on the
// client, so this import only happens client-side).
//
// We import the pre-built `dist/tiny-slider.js` (single-file CJS
// bundle) rather than the raw `src/tiny-slider`. The ESM source
// exports an unbound `requestAnimationFrame` reference; modern
// bundlers turn the import into namespace-object access
// (`mod.raf(...)`) which sets `this = mod` and trips an
// "Illegal invocation" inside the browser API on every pan/drag.
// The CJS bundle keeps `raf(...)` as a bare call, so it works.
// The dist path is explicit because some bundlers (Bun) prefer
// the ESM `src/` even when `main` points at the dist bundle.
var { tns } = await import('tiny-slider/dist/tiny-slider.js');
var settings = {
container: this.$el,
axis: this.axis,
items: parseInt(this.items),
mode: this.mode,
gutter: this.gutter,
edgePadding: this.edgePadding,
fixedWidth: !this.fixedWidth ? this.fixedWidth : parseInt(this.fixedWidth, 10),
viewportMax: this.viewportMax,
slideBy: this.slideBy,
controls: this.controls,
controlsPosition: this.controlsPosition,
controlsText: this.controlsText,
controlsContainer: this.controlsContainer,
prevButton: this.prevButton,
nextButton: this.nextButton,
nav: this.nav,
navPosition: this.navPosition,
navContainer: this.navContainer,
navAsThumbnails: this.navAsThumbnails,
arrowKeys: this.arrowKeys,
speed: this.speed,
autoplay: this.autoplay,
autoplayTimeout: this.autoplayTimeout,
autoplayDirection: this.autoplayDirection,
autoplayText: this.autoplayText,
autoplayHoverPause: this.autoplayHoverPause,
autoplayButton: this.autoplayButton,
autoplayButtonOutput: this.autoplayButtonOutput,
autoplayResetOnVisibility: this.autoplayResetOnVisibility,
animateIn: this.animateIn,
animateOut: this.animateOut,
animateNormal: this.animateNormal,
animateDelay: this.animateDelay,
loop: this.loop,
rewind: this.rewind,
autoHeight: this.autoHeight,
responsive: this.responsive,
lazyload: this.lazyload,
touch: this.touch,
mouseDrag: this.mouseDrag,
nested: this.nested,
freezable: this.freezable,
disable: this.disable,
onInit: this.onInit,
swipeAngle: this.swipeAngle,
startIndex: this.startIndex,
center: this.center,
lazyLoadSelector: this.lazyLoadSelector,
preventActionWhenRunning: this.preventActionWhenRunning,
preventScrollOnTouch: this.preventScrollOnTouch,
autoWidth: this.autoWidth,
useLocalStorage: this.useLocalStorage
};
removeUndefinedProps(settings);
this.slider = tns(settings);
// Emit init event
this.$emit('init');
// Subscribe to all kind of tiny-slider events
this.$_vueTinySlider_subscribeToAll();
},
},
render: function(){
return h('div', this.$slots.default ? this.$slots.default() : []);
}
};
function removeUndefinedProps(obj) {
for (var prop in obj) {
if (obj.hasOwnProperty(prop) && obj[prop] === undefined) {
delete obj[prop];
}
}
}
export default VueTinySlider;
================================================
FILE: tests/index.test.js
================================================
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
// Capture the mock so each test can inspect/drive it.
const tnsMock = vi.fn();
vi.mock('tiny-slider/dist/tiny-slider.js', () => ({
tns: (...args) => tnsMock(...args)
}));
import VueTinySlider from '../src/index.js';
function makeFakeSlider(overrides = {}) {
return {
events: { on: vi.fn() },
goTo: vi.fn(),
rebuild: vi.fn(function () { return this; }),
getInfo: vi.fn(() => ({ index: 0 })),
destroy: vi.fn(),
...overrides
};
}
// init() is async (lazy-imports tiny-slider), so after mount we flush
// microtasks before assertions that depend on the slider being ready.
async function mountSlider(props = {}, slots = undefined) {
const wrapper = mount(VueTinySlider, {
props,
slots: slots ?? {
default: 'A
B
'
}
});
await flushPromises();
return wrapper;
}
beforeEach(() => {
tnsMock.mockReset();
tnsMock.mockImplementation(() => makeFakeSlider());
});
describe('VueTinySlider', () => {
it('renders default slot children into a ', async () => {
const wrapper = await mountSlider();
expect(wrapper.element.tagName).toBe('DIV');
expect(wrapper.findAll('.s')).toHaveLength(2);
});
it('calls tns() on mount when autoInit is true (default)', async () => {
await mountSlider();
expect(tnsMock).toHaveBeenCalledTimes(1);
});
it('does NOT call tns() on mount when autoInit is false', async () => {
await mountSlider({ autoInit: false });
expect(tnsMock).not.toHaveBeenCalled();
});
it('passes the component root element as container', async () => {
const wrapper = await mountSlider();
const settings = tnsMock.mock.calls[0][0];
expect(settings.container).toBe(wrapper.element);
});
it('forwards key props to tns(), coercing items to int', async () => {
await mountSlider({ items: '3', mode: 'gallery', loop: false, mouseDrag: true });
const settings = tnsMock.mock.calls[0][0];
expect(settings.items).toBe(3);
expect(settings.mode).toBe('gallery');
expect(settings.loop).toBe(false);
expect(settings.mouseDrag).toBe(true);
});
it('fixedWidth passes through as false when falsy, or parsed int when set', async () => {
await mountSlider();
expect(tnsMock.mock.calls[0][0].fixedWidth).toBe(false);
tnsMock.mockClear();
await mountSlider({ fixedWidth: '250' });
expect(tnsMock.mock.calls[0][0].fixedWidth).toBe(250);
});
it('strips undefined props before calling tns()', async () => {
// `axis` has no default, so it will be undefined unless set.
await mountSlider();
const settings = tnsMock.mock.calls[0][0];
expect('axis' in settings).toBe(false);
});
it('emits init after tns() is created', async () => {
const wrapper = await mountSlider();
expect(wrapper.emitted('init')).toBeTruthy();
expect(wrapper.emitted('init')).toHaveLength(1);
});
it('subscribes to every tiny-slider event in eventsList and re-emits them', async () => {
const fake = makeFakeSlider();
tnsMock.mockImplementationOnce(() => fake);
const wrapper = await mountSlider();
const expected = VueTinySlider.eventsList;
expect(fake.events.on).toHaveBeenCalledTimes(expected.length);
const subscribedNames = fake.events.on.mock.calls.map(c => c[0]);
expect(subscribedNames).toEqual(expected);
// Simulate tiny-slider firing indexChanged; component should $emit it.
const indexChangedHandler = fake.events.on.mock.calls.find(
c => c[0] === 'indexChanged'
)[1];
indexChangedHandler({ index: 4 });
expect(wrapper.emitted('indexChanged')).toBeTruthy();
expect(wrapper.emitted('indexChanged')[0]).toEqual([{ index: 4 }]);
});
it('goTo(value) delegates to the underlying slider', async () => {
const fake = makeFakeSlider();
tnsMock.mockImplementationOnce(() => fake);
const wrapper = await mountSlider();
wrapper.vm.goTo(2);
expect(fake.goTo).toHaveBeenCalledWith(2);
});
it('rebuild() replaces slider and emits rebuild', async () => {
const fake = makeFakeSlider();
tnsMock.mockImplementationOnce(() => fake);
const wrapper = await mountSlider();
wrapper.vm.rebuild();
expect(fake.rebuild).toHaveBeenCalled();
expect(wrapper.emitted('rebuild')).toBeTruthy();
});
it('getInfo() emits getInfo with payload and slider', async () => {
const info = { index: 7 };
const fake = makeFakeSlider({ getInfo: vi.fn(() => info) });
tnsMock.mockImplementationOnce(() => fake);
const wrapper = await mountSlider();
wrapper.vm.getInfo();
const emitted = wrapper.emitted('getInfo');
expect(emitted).toBeTruthy();
expect(emitted[0][0]).toBe(info);
expect(emitted[0][1]).toBe(fake);
});
it('destroy() calls slider.destroy', async () => {
const fake = makeFakeSlider();
tnsMock.mockImplementationOnce(() => fake);
const wrapper = await mountSlider();
wrapper.vm.destroy();
expect(fake.destroy).toHaveBeenCalled();
});
it('destroys the slider when the component is unmounted', async () => {
const fake = makeFakeSlider();
tnsMock.mockImplementationOnce(() => fake);
const wrapper = await mountSlider();
wrapper.unmount();
expect(fake.destroy).toHaveBeenCalled();
});
it('controlsText default is [prev, next]', async () => {
await mountSlider();
expect(tnsMock.mock.calls[0][0].controlsText).toEqual(['prev', 'next']);
});
});
================================================
FILE: tests/ssr.test.js
================================================
// @vitest-environment node
// Regression guard: importing the component in a pure Node context
// (no window, no document, no DOM globals) must NOT throw. This catches
// top-level browser references leaking back into the module eval path.
import { describe, it, expect } from 'vitest';
describe('SSR safety', () => {
it('component module can be imported in Node without DOM globals', async () => {
const mod = await import('../src/index.js');
expect(mod.default).toBeTypeOf('object');
expect(mod.default.props).toBeTypeOf('object');
});
it('does not reference window/document at module-eval time', async () => {
// Assert absence of the browser globals before import — proving
// the happy-dom env isn't leaking into this test.
expect(typeof window).toBe('undefined');
expect(typeof document).toBe('undefined');
// Re-import is a no-op (cached), but keeps the assertion meaningful.
await import('../src/index.js');
});
});
================================================
FILE: tests/types-check.ts
================================================
// Smoke-check the published types from a consumer perspective.
// Run: npx tsc --noEmit tests/types-check.ts (or let CI do it)
import VueTinySlider, {
type VueTinySliderProps,
type VueTinySliderInstance,
type TinySliderInfo,
type SilderEvent
} from '../src/index.js';
// Default export is a Vue component.
const _component = VueTinySlider;
// Prop interface accepts documented options.
const _props: VueTinySliderProps = {
items: 3,
gutter: 20,
loop: false,
mouseDrag: true,
autoInit: false,
controlsPosition: 'bottom',
controlsText: ['prev', 'next'],
mode: 'carousel',
autoplay: true,
preventScrollOnTouch: 'auto'
};
// Instance shape available via $refs.
const _fakeRef: VueTinySliderInstance = {
slider: null,
init: () => Promise.resolve(),
goTo: (_t: number | 'next' | 'prev' | 'first' | 'last') => {},
rebuild: () => {},
getInfo: () => {},
destroy: () => {}
};
// Event name type is re-exported from tiny-slider.
const _evt: SilderEvent = 'indexChanged';
const _info: TinySliderInfo = {} as TinySliderInfo;
export { _component, _props, _fakeRef, _evt, _info };
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"noEmit": true
},
"include": ["src/**/*.d.ts", "tests/types-check.ts"]
}
================================================
FILE: vite.config.js
================================================
import { defineConfig } from 'vite';
import path from 'path';
export default defineConfig({
build: {
lib: {
entry: path.resolve(__dirname, 'src/index.js'),
name: 'vue-tiny-slider',
fileName: () => 'index.js',
formats: ['umd']
},
rollupOptions: {
// Keep vue and tiny-slider as runtime externals. tiny-slider must
// not be inlined because it touches `document`/`window` at module
// eval time — see SSR notes in README.
external: ['vue', /^tiny-slider(\/|$)/],
output: {
globals: {
vue: 'Vue',
'tiny-slider': 'tns'
}
}
},
sourcemap: true
}
});
================================================
FILE: vitest.config.js
================================================
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'happy-dom',
globals: false,
include: ['tests/**/*.test.js']
}
});