Configurable Vue 3 plugin that registers only the directives you choose. Zero runtime dependencies (peer: vue). UI-library agnostic (DOM APIs only).
Current release: 1.2.0 on npm · Source: github.com/bUxEE/dirigo
npm install @woptima/dirigoimport { createApp } from 'vue'
import App from './App.vue'
import { Dirigo } from '@woptima/dirigo'
const app = createApp(App)
app.use(Dirigo, {
directives: 'all', // or e.g. ['click-copy', 'html-safe', 'case']
prefix: '', // optional e.g. 'vd' → v-vd-click-copy
directiveConfig: {
imageOverlay: {
// backdropStyle: 'rgba(0,0,0,0.1)',
// component: MyImageModal, // props: value (image URL), onClose
},
clickCopy: {
// toastComponent: MyCopyToast, // prop: message (label text)
// duration: 2500,
},
},
})
app.mount('#app')import { clickCopyDirective } from '@woptima/dirigo'
app.directive('click-copy', clickCopyDirective)| Option | Type | Description |
|---|---|---|
directives |
DirectiveName[] | 'all' |
Which directives to register globally. |
prefix |
string? |
Prepended to each name (prefix: 'vd' → vd-click-copy). |
directiveConfig |
DirigoDirectiveConfig? |
Shared UI defaults (e.g. imageOverlay, clickCopy toast). |
| Name | Purpose |
|---|---|
size-vars |
Publishes --{id}-width / --{id}-height on :root (element needs id). |
next-pad |
Sets next sibling padding-top to this element’s height. |
case |
Enforces 'uppercase' or 'lowercase' on <input> / <textarea> + syncs v-model (v-case="'uppercase'" / v-case="'lowercase'"). |
type-value |
On blur, normalizes v-model (kind: text | number | integer | float); invalid numeric strings clear to ''. |
input-type |
Key filter modes: integer, decimal, digits, phone, numpad, slug. |
transitioning |
Adds transitioning class around CSS transitions (optional property filter). |
drop-file |
onDrop(files) + drag-over class. |
paste-file |
{ onPaste(files) } for clipboard files (no custom DOM events). |
image-overlay |
Full-screen image viewer on <img> or a wrapper: default modal, or { component, componentProps }. Optional callback-only: (src) => void or { onOpen } without component / overlay: true. |
click-copy |
Copy on click + toast (value, label, optional toastComponent, duration). |
link-copy |
Copy URL / href + toast (same toast options as click-copy). |
html-safe |
Like v-html with default allowlist sanitizer; optional sanitize(html). |
sticky |
Applies position: sticky + offsets (top, bottom, zIndex, …). |
strip-html-paste |
Plain-text paste for inputs / contenteditable. |
scroll-lock |
Locks body scroll (ref-count); optional { target }. |
scroll-spy |
Scroll-linked “active” section: viewport = trigger-line box (default: host); root = scroll target (inferred: nearest vertical scroll container from that box when omitted). |
media-class |
Adds classes for coarse, reducedMotion, dark media queries. |
hash-scroll |
Scrolls to id when location.hash matches. |
select-on-focus |
Select-all on focus. |
skeleton |
Loading shimmer overlay; host keeps layout while content is visibility: hidden; optional theme, delay, custom component. |
debounce-input |
Debounces input; listen for debounced-input custom event. |
view-classes |
view-visible when ≥1px in view; directional classes (entering-top, entering-bottom, leaving-top, leaving-bottom) while scrolling and partially visible. |
more-text |
Truncates text with Show more / Show less (maxLength, labels). |
is-scrolling |
has-vertical-scroll / has-horizontal-scroll when overflow exists; is-scrolling-vertical / is-scrolling-horizontal when scrolled (scrollTop > 0 / scrollLeft > 0, LTR). |
page-visibility-class |
Tab hidden/visible classes. |
img-fallback |
Placeholder src on <img> error. |
keybind |
Hotkey map (esc, ctrl+enter, …) → handlers; optional focusWithin / disabled. |
infinite-scroll |
IntersectionObserver on scroll root: { load, disabled?, distance?, rootMargin?, throttleMs? }. |
longpress |
Pointer long-press (handler, duration?, preventContextMenu?). |
truncate |
Truncates element textContent to length (number or { length, ellipsis? }). |
drag-select |
Click-drag box selection with { items, selected, key? }; children use data-{key} matching items (Ctrl/Meta toggles, Shift range). |
empty-state |
When items is empty: hides default slot and shows empty UI (text, icon, component, ctaText / onCta). |
form-lock |
When binding is true, disables inputs/buttons inside the host; restores prior disabled when unlocked. |
error-focus |
On first invalid (HTML5 validation), focus + optional smooth scrollIntoView / shake; optional ariaInvalid / findFirstInvalid. |
float-to |
Toggle: animate host to match a target element’s screen rect (fixed + optional transition); ResizeObserver keeps it aligned. Second click fades out and restores layout. |
- Object:
{ html: string, sanitize?: (html: string) => string }or allowlist options for the built-in sanitizer. - String shorthand: same HTML string, default sanitizer.
- Security: default sanitizer is best-effort; for untrusted HTML prefer server-side filtering and/or pass DOMPurify (your dependency) via
sanitize.
- Host: put
v-image-overlayon the<img>(uses that image’ssrc/currentSrc) or on a wrapper (uses the first nested<img>). Optional binding{ src: '…' }overrides the URL. - Modal (default): click opens a built-in full-screen overlay (backdrop, centered image constrained to the viewport, white × control top-right,
Escapecloses). Override globally withdirectiveConfig.imageOverlay(component,backdropStyle,padding) or per binding:{ component: MyModal, componentProps?: { … } }. Custom components receivevalue(image URL string) andonClose. - Callback-only:
v-image-overlay="(src) => …"or{ onOpen }withoutcomponentand withoutoverlay: true— runs the handler instead of opening the modal.
- Per binding:
{ value, label, toastComponent?, duration? }. - Global:
directiveConfig.clickCopy.toastComponent(Vue component with propmessagefor the label) andduration(ms).
<input v-paste-file="{ onPaste: (files) => console.log(files) }" />
<div v-drop-file="{ onDrop: (files) => upload(files) }"></div>Required binding: 'uppercase' or 'lowercase' (or a ref holding either). Invalid values log a dev warning and do not transform.
<input v-model="code" v-case="'uppercase'" />
<textarea v-model="note" v-case="'lowercase'" />type-value: on blur, normalizes the value:integeronly accepts a full signed integer string (12abcorasdasdas→ cleared);float/numberuseNumber()on the whole string (12abc→ cleared). Empty input becomes''. HTML inputs cannot bindNaN; in script use e.g.const n = num === '' ? NaN : Number(num)if you need it.input-type: blocks disallowed keydown characters (optional; separate fromtype-value).
<input v-model="n" v-type-value="{ kind: 'float' }" />
<input v-input-type="{ mode: 'phone' }" /><p v-more-text="{ maxLength: 120, moreLabel: 'Show more', lessLabel: 'Show less' }">Long text …</p>- Overflow:
has-vertical-scroll/has-horizontal-scrollwhen content overflows that axis. - Scrolled:
is-scrolling-verticalwhenscrollTop > 0;is-scrolling-horizontalwhenscrollLeft > 0(LTR origin). Optional overrides:{ hasVertical, hasHorizontal, scrolledVertical, scrolledHorizontal }. Deprecated:vertical/horizontalmap to the has- names.
<div v-keybind="{ esc: onClose, 'ctrl+enter': onSubmit, focusWithin: true }"></div>Uses capture on window. Modifiers: ctrl, meta/cmd, alt, shift. Keys: esc, enter, single letters, etc.
Host must be the scroll container (overflow: auto / scroll). A sentinel is appended; when it intersects (near bottom), load runs. Set disabled: true while fetching.
v-longpress="onLongPress" or v-longpress="{ handler: onLongPress, duration: 600 }".
v-truncate="40" or v-truncate="{ length: 40, ellipsis: '…' }" (best with static text; reactive copy changes may need :key on the host).
- Binding:
{ target, transition?, trigger? }.target:HTMLElement, VueRef<HTMLElement | null>, or a CSS selector (viadocument.querySelector).transition: CSStransitionfortop/left/width/height(default ~400ms cubic-bezier).trigger: selector within the host for the clickable node; omit to use the host as the click target. - Behavior: first click captures the host’s
getBoundingClientRect()(viewport), moves the host todocument.bodyfor that flight, appliesposition: fixedwith a highz-index, then animatestop/left/width/heightto the target’s viewport rect (same coordinate system). While flying and docked, the host has classfloated-to(exportFLOAT_TO_DOCKED_CLASS) for hooks or styling; it is removed when returning or unmounting. Without the body move, any ancestortransform/filter/perspectivewould makefixeduse a different containing block than the viewport, so pixel rects would be wrong. After the return fade, the host is inserted back at its original DOM position. While docked, the host tracks the target viaResizeObserver(element box changes) pluswindowresize,documentscroll(capture) so window resize and scroll (which movegetBoundingClientRect()without firing RO) stillsync;visualViewportresize/scrollare included when available.transitionendfrom descendants is ignored for the fade so nested controls do not reset the host early. - Templates: like
v-scroll-spy, avoidv-float-to="{ target: myRef }"— Vue unwrapsmyRefin inline objects. Usecomputed(() => ({ target: myRef, … }))andv-float-to="opts".
Place on a <form> (or an element inside one). Listens for the bubbling invalid event (HTML5 constraint validation: required, pattern, setCustomValidity, etc.).
scroll:true(default) — afterfocus(), callsscrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }).false— no scroll into view.shake: optional short CSS shake on the focused field (defaulttrue).ariaInvalid: if the form has no:invalidelement but there is[aria-invalid="true"], focus that node instead of the event target. Useful when a library marks errors only with ARIA (some Vuetify setups) while the browser still emittedinvalidfrom a related control.findFirstInvalid(form): return theHTMLElementto focus yourself (VeeValidate, custom rules, errors on wrappers). Use this when validation is not expressed as native validity on the right node, or when you need a specific field order.
Vuetify / VeeValidate / other libs: There is no automatic “library detection”. The directive uses the DOM: the invalid event and optionally :invalid, [aria-invalid="true"], or your findFirstInvalid. Purely JS-driven forms that never call reportValidity() or never produce invalid events will not trigger this unless you wire validation so those events fire, or you use findFirstInvalid and ensure your submit path still surfaces an invalid event (e.g. hidden native inputs) — otherwise focus the field from your validator and skip this directive for that path.
- Binding: a Vue
Component, or{ loading?, component?, props?, theme?, delay?, minDuration?, debug? }.loadingdrives the overlay (boolean or ref; omitted means loading). Pass acomponentto replace the built-in shimmer; its props merge withprops(and the built-in receives athemeprop when using the default skeleton). theme:dark|light— palette for the built-in shimmer only (darkdefault: slate-style gradient;light: light gray for pale surfaces). Ignored if you pass a customcomponent(unless you forwardthemeyourself).delay/minDuration: ms before showing the overlay after loading stays on, and minimum time the overlay stays visible (defaults 120 / 200).debug: log show/hide timing to the console.- Built-in shimmer animation runs on a ~2s cycle.
- Binding:
{ active, selector?, offset?, root?, viewport? }.active(required) is aRef<string>updated with theidof the active block. Templates: do not writev-scroll-spy="{ active: myRef, … }"— Vue unwrapsmyRefinside the object. Useconst opts = computed(() => ({ active: myRef, … }))andv-scroll-spy="opts".selectorqueries from the host (default:scope > *); only elements with anidparticipate.offset(default 0.4) places the trigger line at that fraction of the viewport box height.viewport: omit → host (the element with the directive);'body'→ full-page visual viewport (window.innerHeight); otherwise a selector / element / ref.root: omit → nearest vertical scroll container walking up from the resolved viewport node (same starting point as the default trigger box: the host);viewport: 'body'→window;root: null→ forcewindow; or pass an element / ref. If the scrollable panel is only a child of the host, setviewport(orroot) to that element — inference does not search down into descendants. - Logic: an element is “crossing” when
rect.top <= trigger && rect.bottom > trigger(usinggetBoundingClientRectinsiderequestAnimationFrame). If several cross, scrolling down picks the last, scrolling up the first. If none cross, uses the block closest above the line, else the first block. - Listeners:
scrollonrootorwindow;resizeonwindowto refresh the cached node list.
Mounted element triggers body scroll lock until unmounted (nested modals: ref-counted).
| Old | New |
|---|---|
vue-directives-plus (package) |
@woptima/dirigo — plugin export Dirigo, createDirigo, types DirigoOptions, DirigoDirectiveConfig |
uppercase |
v-case with 'uppercase' or 'lowercase' |
numberValue |
v-type-value |
numberInput / phoneInput / … |
v-input-type with mode |
v-transition |
v-transitioning |
pasteFile |
v-paste-file + { onPaste } (no custom DOM event) |
height-style |
use v-size-vars + CSS var(--id-height) |
scroll-shadow* |
handle in CSS / app |
scrolling-vertical / scrolling-horizontal (old is-scrolling) |
has-vertical-scroll / has-horizontal-scroll + is-scrolling-vertical / is-scrolling-horizontal |
npm install
npm run playground
npm test
npm run buildNot included in the npm package. Separate Vue app: home, playground (reuses playground/App.vue), docs/API table.
npm run site:dev
npm run site:build
npm run site:previewSee docs/REPOSITORY_LAYOUT.md for deploying to your domain and splitting public library vs private site across two repos.
npm run release:npm:dry # test + build + npm publish --dry-run (no version bump)
npm run release:npm # patch version + publish (local git tag/commit from npm version)
RELEASE_NO_GIT=1 npm run release:npm # same without git moves (CI-friendly)MIT