npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

cetera-vue-utils

v0.3.61

Published

Vue 3 components, composables and utilities

Readme

cetera-vue-utils

Components, composables, and utilities for Vue 3.

Playground →

Table of Contents

Components

Composables

Installation

npm install cetera-vue-utils

The package requires Vue as a peer dependency:

npm install vue

Nuxt

Add the module to nuxt.config.ts:

export default defineNuxtConfig({
    modules: ['cetera-vue-utils/nuxt'],
})

The module automatically:

  • registers all components globally
  • adds composables to auto-imports: useNotify, useFormElements, useErrors, useDataLoad

Since the components use Tailwind CSS classes, add a @source directive to your CSS entry point so Tailwind scans the library's dist files. Also import the library CSS (required for InputDate and InputTime datepicker styles):

/* app/assets/css/main.css */
@import "tailwindcss";
@import "cetera-vue-utils/style.css";
@source "../../../node_modules/cetera-vue-utils/dist/";

Note: cetera-vue-utils/style.css resolves to dist/style.css, which contains the compiled component CSS including the @vuepic/vue-datepicker base styles needed by InputDate and InputTime. The @source directive ensures your project's Tailwind generates the primary-* and other utility classes used by the components.

The Notifications component must be placed manually in the root layout:

<!-- app/app.vue -->
<script setup>
import { Notifications } from 'cetera-vue-utils'
</script>

<template>
    <Notifications />
    <NuxtLayout>
        <NuxtPage />
    </NuxtLayout>
</template>

Components

Notifications

Displays active notifications. Place once in the root layout.

Styled with Tailwind CSS — requires Tailwind to be configured in your project.

<script setup>
import { Notifications, useNotify } from 'cetera-vue-utils'

const notify = useNotify()
</script>

<template>
  <Notifications />
  <button @click="notify.success('Done!')">Save</button>
</template>

Notifications appear in the top-right corner with enter/leave animations.

Spinner

Loading overlay on top of content. Requires position: relative on the parent element.

<template>
  <div class="relative">
    <Spinner :isLoading="loading" size="lg" />
    <p>Content</p>
  </div>
</template>

<script setup>
import { Spinner } from 'cetera-vue-utils'
</script>

| Prop | Type | Default | Description | |---|---|---|---| | isLoading | boolean | true | Show spinner | | size | 'xs' \| 'sm' \| 'base' \| 'lg' \| 'xl' | 'xl' | Icon size |

InlineLoading

Inline loading indicator with optional text.

<template>
  <InlineLoading :isLoading="loading" loadingText="Saving..." />
</template>

<script setup>
import { InlineLoading } from 'cetera-vue-utils'
</script>

| Prop | Type | Default | Description | |---|---|---|---| | isLoading | boolean | true | Show indicator | | loadingText | string | 'Loading ...' | Text next to icon |

Button

Button with style variants, sizes, and loading state.

<template>
  <Button label="Save" @click="save" />
  <Button label="Loading" :loading="true" />
  <Button label="Secondary" severity="secondary" />
  <Button label="Delete" severity="danger" :icon="TrashIcon" />
  <Button label="Outlined" outlined />
  <Button label="Small" size="sm" />
  <Button label="Large" size="lg" />
</template>

<script setup>
import { Button } from 'cetera-vue-utils'
import { TrashIcon } from '@heroicons/vue/24/outline'
</script>

| Prop | Type | Default | Description | |---|---|---|---| | label | string | — | Button text | | severity | 'primary' \| 'secondary' \| 'danger' | 'primary' | Color variant | | outlined | boolean | false | Outline style (transparent background) | | size | 'sm' \| 'md' \| 'lg' | 'md' | Button size | | loading | boolean | false | Spinner + disabled state | | icon | FunctionalComponent | — | Icon (hidden when loading) |

| Slot | Description | |---|---| | default | Button content (alternative to label prop) |

All other attrs (type, disabled, etc.) are forwarded to <button>.

ButtonLink

Button styled as a link (<a>) with the same style variants.

<template>
  <ButtonLink href="/dashboard">Dashboard</ButtonLink>
  <ButtonLink href="https://example.com" target="_blank" severity="secondary">External link</ButtonLink>
  <ButtonLink href="/docs" outlined size="sm">Docs</ButtonLink>
</template>

<script setup>
import { ButtonLink } from 'cetera-vue-utils'
</script>

| Prop | Type | Default | Description | |---|---|---|---| | label | string | — | Text (alternative to slot) | | severity | 'primary' \| 'secondary' \| 'danger' | 'primary' | Color variant | | outlined | boolean | false | Outline style | | size | 'sm' \| 'md' \| 'lg' | 'md' | Size | | icon | FunctionalComponent | — | Icon |

| Slot | Description | |---|---| | default | Link content (alternative to label prop) |

All attrs (href, target, rel, etc.) are forwarded to <a>.

Dialog

Modal dialog based on @headlessui/vue with animation and backdrop.

<template>
  <Button label="Open" @click="visible = true" />
  <Dialog v-model:visible="visible" header="Title">
    <p>Dialog content</p>
    <template #footer>
      <Button label="Cancel" severity="secondary" @click="visible = false" />
      <Button label="Save" @click="save" />
    </template>
  </Dialog>
</template>

<script setup>
import { ref } from 'vue'
import { Dialog, Button } from 'cetera-vue-utils'

const visible = ref(false)
</script>

Custom header via slot:

<Dialog v-model:visible="visible">
  <template #header>
    <h2 class="text-xl font-bold text-red-600">Warning!</h2>
  </template>
  Content
</Dialog>

| Prop | Type | Description | |---|---|---| | visible (v-model) | boolean | Dialog visibility | | header | string | Title (alternative to #header slot) | | contentClass | string | Extra class for the content wrapper <div> |

| Slot | Description | |---|---| | default | Dialog content | | header | Custom header (overrides header prop) | | footer | Action buttons (right-aligned) |

Attrs (e.g. class) are forwarded to DialogPanel — use to override width:

<Dialog v-model:visible="visible" header="Wide dialog" class="max-w-3xl">...</Dialog>

ConfirmAction

Confirmation dialog. Controlled via ref and the confirm(id) method.

<template>
  <Button label="Delete" severity="danger" @click="confirmRef?.confirm(item.id)" />
  <ConfirmAction ref="confirmRef" danger @confirm="onDelete">
    Are you sure you want to delete this record?
  </ConfirmAction>
</template>

<script setup>
import { ref } from 'vue'
import { ConfirmAction, Button } from 'cetera-vue-utils'

const confirmRef = ref()
const onDelete = (id) => {
  // delete record with id
}
</script>

| Prop | Type | Description | |---|---|---| | danger | boolean | Renders the confirm button in red |

| Event | Payload | Description | |---|---|---| | confirm | id: any | Fired after user confirms |

| Method | Description | |---|---| | confirm(id) | Opens the dialog and stores id to pass to the event |

| Slot | Description | |---|---| | default | Confirmation message body |

Input

Wrapper around an input field with label, validation errors, and helper text support.

<template>
  <Input label="Email" v-model="email" :invalidMessage="errors.get('email')" />

  <!-- With a custom component -->
  <Input label="Date" :component="InputDate" v-model="date" />

  <!-- With helper text -->
  <Input label="Password" v-model="password" helperText="Minimum 8 characters" />
</template>

<script setup>
import { Input, InputDate } from 'cetera-vue-utils'
</script>

| Prop | Type | Default | Description | |---|---|---|---| | label | string | — | Field label | | component | Component | InputText | Input component to render | | invalidMessage | string | — | Validation error text | | helperText | string | — | Helper text below the field | | id | string | auto uuid | ID for label/input association | | size | 'sm' \| 'small' \| 'md' \| 'lg' | — | Controls padding/text size (sm: px-2 py-1 text-sm, md/default: px-3 py-2, lg: px-4 py-3 text-lg) |

| Slot | Props | Description | |---|---|---| | default | { invalid: boolean } | Replaces the input element entirely | | invalid-message | — | Custom validation error content | | helper-text | — | Custom helper text content |

Checkbox

Checkbox with label. Supports indeterminate state.

<template>
  <Checkbox v-model="checked" label="I agree to the terms" />
  <Checkbox v-model="value" label="Option" :value="'option1'" name="group" />
  <Checkbox v-model="checked" label="Partial" :indeterminate="true" />
</template>

<script setup>
import { Checkbox } from 'cetera-vue-utils'
</script>

| Prop | Type | Description | |---|---|---| | label | string | Text next to the checkbox | | id | string | ID for label/input association (auto-generated if omitted) | | value | boolean \| string \| number | Value when used in a group | | name | string | Group name | | disabled | boolean | Disable the control | | indeterminate | boolean | Intermediate state |

| Event | Payload | Description | |---|---|---| | change | value: boolean \| string \| number \| undefined | Emitted after each toggle |

Radiobutton

Radio button with label.

<template>
  <Radiobutton v-model="selected" label="Option 1" value="a" name="group" />
  <Radiobutton v-model="selected" label="Option 2" value="b" name="group" />
</template>

<script setup>
import { Radiobutton } from 'cetera-vue-utils'
</script>

| Prop | Type | Description | |---|---|---| | label | string | Text next to the button | | value | string \| number | This button's value | | name | string | Group name | | disabled | boolean | Disable the control |

Toggle

Toggle switch with label.

<template>
  <Toggle v-model="enabled" label="Enable notifications" />
  <Toggle v-model="enabled" label="Active" labelPosition="right" />
</template>

<script setup>
import { Toggle } from 'cetera-vue-utils'
</script>

| Prop | Type | Default | Description | |---|---|---|---| | label | string | — | Label text | | labelPosition | 'top' \| 'right' | 'top' | Label position | | disabled | boolean | — | Disable the control |

Select

Dropdown select backed by @headlessui/vue Listbox.

<template>
  <Select v-model="selected" label="Status" :options="statuses" />
</template>

<script setup>
import { Select } from 'cetera-vue-utils'

const statuses = [
  { name: 'Active', value: 'active' },
  { name: 'Archived', value: 'archived' },
]
</script>

| Prop | Type | Default | Description | |---|---|---|---| | options | any[] | — | List of options | | optionLabel | string | 'name' | Field used for display | | optionValue | string | 'value' | Field used for value | | optionGroupChildren | string | — | Field containing child options — enables nested/grouped tree | | placeholder | string | — | Placeholder text | | showClear | boolean | false | Show clear button | | filter | boolean | — | Show search input inside the dropdown | | filterFields | string[] | — | Option fields to search across (defaults to optionLabel) | | disabled | boolean | false | Disable the control | | size | 'sm' \| 'small' \| 'md' \| 'lg' | — | Input size |

| Event | Payload | Description | |---|---|---| | filter | query: string | Emitted on every keystroke in the filter input |

| Slot | Props | Description | |---|---|---| | option | { option: any } | Custom option rendering |

MultiSelect

Multiple selection backed by @headlessui/vue Listbox with multiple.

<template>
  <MultiSelect v-model="selected" label="Tags" :options="tags" selectedFirst />
</template>

<script setup>
import { MultiSelect } from 'cetera-vue-utils'
</script>

| Prop | Type | Default | Description | |---|---|---|---| | options | any[] | — | List of options | | optionLabel | string | 'name' | Field used for display | | optionValue | string | 'value' | Field used for value | | optionGroupChildren | string | — | Field containing child options — enables nested/grouped tree | | optionGroupSelectable | boolean | — | Make group headers clickable to select/deselect all children | | placeholder | string | — | Placeholder text | | showClear | boolean | false | Show clear button | | selectAll | boolean | — | Show Select all / Deselect all checkbox in the dropdown header | | filter | boolean | — | Show search input inside the dropdown | | filterFields | string[] | — | Option fields to search across (defaults to optionLabel) | | selectedFirst | boolean | — | Show selected options at the top of the list | | selectedLabel | (count: number) => string | — | Custom summary label when items are selected | | disabled | boolean | — | Disable the control | | size | 'sm' \| 'small' \| 'md' \| 'lg' | — | Input size |

| Event | Payload | Description | |---|---|---| | filter | query: string | Emitted on every keystroke in the filter input |

| Slot | Props | Description | |---|---|---| | option | { option: any } | Custom option rendering |

Menu

Dropdown menu — a Popover wrapper with a vertically stacked list of MenuItem buttons. Each MenuItem closes the menu on click. Place <Menu> inside the trigger element (e.g. <Button>) for target=true (default).

<template>
  <Button label="Actions" outlined>
    <Menu placement="bottom-start">
      <MenuItem @click="edit">Edit</MenuItem>
      <MenuItem @click="remove">Delete</MenuItem>
    </Menu>
  </Button>
</template>

<script setup>
import { Button, Menu, MenuItem } from 'cetera-vue-utils'
</script>

Menu

Accepts the same positioning props as Popover.

| Prop | Type | Default | Description | |---|---|---|---| | target | boolean \| string \| HTMLElement | true | Trigger element. true = parent element, false = programmatic only, string = CSS selector, HTMLElement = ref | | placement | 'bottom-start' \| 'bottom-end' \| 'top-start' \| 'top-end' \| 'bottom' \| 'top' | 'bottom-start' | Panel placement | | contextMenu | boolean | false | Open on right-click at cursor position instead of click | | hover | boolean | false | Open on mouseenter, close on mouseleave (with 80 ms grace period to move into the panel) | | autoClose | boolean | true | Close on any click inside the menu — useful when items don't have individual click handlers |

| Slot | Description | |---|---| | default | MenuItem components |

| Method | Description | |---|---| | open(anchor?) | Programmatically open. anchor can be a MouseEvent or HTMLElement |

MenuItem

Renders a full-width <button>. Automatically closes the parent menu on click via inject.

| Prop | Type | Default | Description | |---|---|---|---| | disabled | boolean | — | Native disabled — dims the item and disables interaction |

Forwards all other attributes via v-bind="$attrs" (e.g. class, event handlers).

Slider

Range slider using a native <input type="range">.

<template>
  <Slider v-model="value" label="Volume" :min="0" :max="100" />
</template>

<script setup>
import { Slider } from 'cetera-vue-utils'
</script>

All Input props and native range attrs are forwarded.

Textarea

Multi-line text input with optional auto-resize.

<template>
  <Textarea v-model="text" label="Comment" rows="4" autoResize />
</template>

<script setup>
import { Textarea } from 'cetera-vue-utils'
</script>

All Input props are forwarded. Pass autoResize to grow the textarea with content.

InputDate

Date picker. Stores value as yyyy-MM-dd string.

<template>
  <InputDate v-model="date" label="Date of birth" min-date="2000-01-01" :max-date="today" />
</template>

<script setup>
import { InputDate } from 'cetera-vue-utils'

const date = ref<string | null>(null) // '2024-01-15'
const today = new Date()
</script>

| Prop | Type | Default | Description | |---|---|---|---| | modelValue | string \| null | — | Date as yyyy-MM-dd | | minDate | string \| Date | undefined | Earliest selectable date | | maxDate | string \| Date | undefined | Latest selectable date | | size | InputSize | undefined | Input size | | showClear | boolean | false | Show clear button | | disabled | boolean | false | Disables the picker |

Requires date-fns (included as a dependency).

InputMonth

Month picker. Stores value as YYYY-MM string (e.g. "2025-06").

<template>
  <InputMonth v-model="month" label="Billing month" min-date="2020-01" :max-date="today" />
</template>

<script setup>
import { InputMonth } from 'cetera-vue-utils'

const month = ref<string | null>(null) // '2025-06'
const today = new Date()
</script>

| Prop | Type | Default | Description | |---|---|---|---| | modelValue | string \| null | — | Month as YYYY-MM | | minDate | string \| Date | undefined | Earliest selectable month | | maxDate | string \| Date | undefined | Latest selectable month | | size | InputSize | undefined | Field size | | showClear | boolean | false | Show clear button | | disabled | boolean | false | Disables the picker |

InputTime

Time picker. Stores value as HH:mm string.

<template>
  <InputTime v-model="time" label="Start time" />
</template>

<script setup>
import { InputTime } from 'cetera-vue-utils'

const time = ref<string | null>(null) // '14:30'
</script>

| Prop | Type | Default | Description | |---|---|---|---| | modelValue | string \| null | — | Time as HH:mm | | showClear | boolean | false | Show clear button | | disabled | boolean | false | Disable the picker |

InputFile

File upload with drag-and-drop support. Stores selected files as File[].

<template>
  <InputFile v-model="files" name="docs" label="Documents" multiple upload-max-filesize="10 MB" />
</template>

<script setup>
import { InputFile } from 'cetera-vue-utils'

const files = ref([])
</script>

| Prop | Type | Default | Description | |---|---|---|---| | modelValue | File[] | [] | Selected files | | name | string | — | Input name attribute | | label | string | undefined | Field label | | multiple | boolean | false | Allow multiple files | | disabled | boolean | false | Disable input | | uploadMaxFilesize | string | undefined | Max size hint shown below the dropzone |

InputFileImage

Image upload with drag-and-drop, preview, and delete button. Accepts .jpg, .jpeg, .png, .webp.

<template>
  <InputFileImage v-model="imageUrl" v-model:file="imageFile" name="avatar" label="Avatar" />
</template>

<script setup>
import { InputFileImage } from 'cetera-vue-utils'

const imageUrl = ref(null)  // base64 data URL of the selected image
const imageFile = ref(null) // raw File object
</script>

| Prop | Type | Default | Description | |---|---|---|---| | modelValue | string \| null | undefined | Base64 data URL of the image | | file (v-model) | File \| null | undefined | Raw File object | | name | string | 'files' | Input name attribute | | label | string | undefined | Field label | | disabled | boolean | false | Disable input | | invalidMessage | string | undefined | Validation error message | | uploadMaxFilesize | string | undefined | Max size hint shown below the dropzone |

| Slot | Description | |---|---| | invalid-message | Custom validation error content |

InputNumber

Numeric input using a native <input type="number">.

<template>
  <InputNumber v-model="amount" label="Amount" />
</template>

<script setup>
import { InputNumber } from 'cetera-vue-utils'
</script>

All Input props are forwarded.

InputPassword

Password field with show/hide toggle.

<template>
  <InputPassword v-model="password" label="Пароль" placeholder="Введите пароль" />
</template>

<script setup>
import { ref } from 'vue'
import { InputPassword } from 'cetera-vue-utils'

const password = ref('')
</script>

All Input props (label, invalidMessage, helperText, etc.) are supported.

InputSearch

Search field with icon, clear button, and debounce. Requires @heroicons/vue and @vueuse/core.

<template>
  <InputSearch v-model="query" :debounce="500" :loading="isSearching" placeholder="Search..." />
</template>

<script setup>
import { InputSearch } from 'cetera-vue-utils'
</script>

| Prop | Type | Default | Description | |---|---|---|---| | modelValue | string | '' | Search value | | debounce | number | 800 | Delay before emit (ms) | | loading | boolean | false | Shows spinner instead of clear icon | | size | 'sm' \| 'small' \| 'md' \| 'lg' | | Controls padding/text size |

Tabs

Tab component based on provide/inject. Tab styling is fully controlled via Tailwind.

<template>
  <Tabs v-model:value="activeTab">
    <TabList class="border-b border-gray-200 gap-0 mb-4">
      <Tab
        v-for="tab in tabs"
        :key="tab.value"
        :value="tab.value"
        :class="[
          'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition',
          activeTab === tab.value
            ? 'border-primary-500 text-primary-600'
            : 'border-transparent text-gray-500 hover:text-gray-700',
        ]"
      >
        {{ tab.label }}
      </Tab>
    </TabList>

    <TabPanel value="one"><p>First tab</p></TabPanel>
    <TabPanel value="two"><p>Second tab</p></TabPanel>
  </Tabs>
</template>

<script setup>
import { ref } from 'vue'
import { Tabs, TabList, Tab, TabPanel } from 'cetera-vue-utils'

const activeTab = ref('one')
</script>

| Component | Prop | Description | |---|---|---| | Tabs | value (v-model) | Active tab value | | Tabs | lazy | Mount TabPanel content only when first activated | | Tabs | keepHeight | Inactive panels use visibility:hidden instead of display:none. Use with <TabPanels> wrapper — it automatically becomes a CSS grid that overlaps all panels, so the container height equals the tallest panel. | | Tab | value | Unique tab identifier | | Tab | as | HTML tag or component (default: button) | | TabPanel | value | Shown when it matches Tabs.value | | TabPanels | — | Optional wrapper for keepHeight mode; auto-applies CSS grid to overlay all panels |

TabList is a role="tablist" wrapper that accepts any classes. Tab sets aria-selected automatically. The class attribute on Tab is forwarded to the overflow MenuItem, so layout classes like flex items-center gap-2 apply in both contexts.

TabList props:

| Prop | Type | Default | Description | |---|---|---|---| | activeVisible | boolean | false | When true, selecting a hidden tab from the overflow menu moves it to the first visible position; also highlights the "More" button border when the active tab is hidden | | placement | 'bottom-start' \| 'bottom-end' \| 'top-start' \| 'top-end' \| 'bottom' \| 'top' | 'bottom-end' | Placement of the overflow dropdown menu |

TabList slots:

| Slot | Description | |---|---| | default | Tab items (<Tab> components) | | #more | Overflow button label. Scoped: { count: number }. Default content: More N ... |

Accordion

Collapsible panels. Supports single and multiple open modes.

<template>
  <Accordion :multiple="true">
    <AccordionPanel value="one">
      <AccordionHeader class="px-4 py-3 text-sm font-medium">
        Header
      </AccordionHeader>
      <AccordionContent class="px-4 py-3 text-sm text-gray-600">
        Panel content
      </AccordionContent>
    </AccordionPanel>
    <AccordionPanel value="two">
      <AccordionHeader class="px-4 py-3 text-sm font-medium">
        Another header
      </AccordionHeader>
      <AccordionContent class="px-4 py-3 text-sm text-gray-600">
        Another content
      </AccordionContent>
    </AccordionPanel>
  </Accordion>
</template>

<script setup>
import { Accordion, AccordionPanel, AccordionHeader, AccordionContent } from 'cetera-vue-utils'
</script>

| Prop | Component | Type | Default | Description | |---|---|---|---|---| | multiple | Accordion | boolean | false | Allow multiple panels open at once | | value | Accordion | (string \| number)[] | — | Array of open panel values (v-model) | | value | AccordionPanel | string \| number | — | Unique panel identifier |

AccordionHeader renders a button with an animated chevron. AccordionContent opens with a smooth height animation.

Skeleton

Placeholder for loading content with a pulse animation.

<template>
  <!-- Text lines -->
  <Skeleton height="1rem" width="100%" />
  <Skeleton height="1rem" width="75%" />

  <!-- Avatar + text -->
  <div class="flex items-center gap-3">
    <Skeleton shape="circle" width="3rem" height="3rem" />
    <div class="flex-1 space-y-2">
      <Skeleton height="0.875rem" width="40%" />
      <Skeleton height="0.75rem" width="60%" />
    </div>
  </div>
</template>

<script setup>
import { Skeleton } from 'cetera-vue-utils'
</script>

| Prop | Type | Default | Description | |---|---|---|---| | width | string | '100%' | Block width | | height | string | '1rem' | Block height | | shape | 'rectangle' \| 'circle' | 'rectangle' | Shape: rectangle or circle |

Draggable

Sortable list component based on SortableJS. Supports drag between lists, multi-drag, and cloning.

<template>
  <!-- Sortable list -->
  <Draggable v-model="items" item-key="id" tag="ul" ghost-class="opacity-40">
    <template #item="{ element }">
      <li class="cursor-grab">{{ element.label }}</li>
    </template>
  </Draggable>

  <!-- Drag between two lists -->
  <Draggable v-model="listA" item-key="id" group="shared">
    <template #item="{ element }"><div>{{ element.label }}</div></template>
  </Draggable>
  <Draggable v-model="listB" item-key="id" group="shared">
    <template #item="{ element }"><div>{{ element.label }}</div></template>
  </Draggable>
</template>

<script setup>
import { ref } from 'vue'
import { Draggable } from 'cetera-vue-utils'

const items = ref([
  { id: 1, label: 'Item A' },
  { id: 2, label: 'Item B' },
])
</script>

| Prop | Type | Default | Description | |---|---|---|---| | modelValue | any[] | — | List (use with v-model) | | list | any[] | — | List (mutated in place; mutually exclusive with v-model) | | itemKey | string \| Function | — | Field or function to produce a unique key per item | | tag | string | 'div' | Root element tag | | group | string \| object | — | SortableJS group — enables drag between lists | | clone | Function | — | Custom clone function for pull: 'clone' | | move | Function | — | Callback to validate a move; return false to cancel | | multiDrag | boolean | false | Enable multi-item drag (requires SortableJS MultiDrag plugin) |

All other props are forwarded as SortableJS options (e.g. ghost-class, chosen-class, animation, handle, filter, sort, disabled).

@change emits { added }, { removed }, or { moved } after each reorder.

DataTable

Data table that reads column definitions from Column child components.

<template>
  <DataTable :value="users" scrollable scroll-height="400px">
    <Column field="name" header="Name" />
    <Column field="email" header="Email" />
    <Column header="Actions">
      <template #body="{ data }">
        <Button label="Edit" size="sm" @click="edit(data)" />
      </template>
    </Column>
  </DataTable>
</template>

<script setup>
import { DataTable, Column, Button } from 'cetera-vue-utils'
</script>

| Prop | Type | Default | Description | |---|---|---|---| | value | any[] | — | Row data | | scrollable | boolean | false | Enable scrollable body | | scrollHeight | string | — | Fixed height for scrollable body (e.g. '400px') | | stickyHeader | boolean | false | Stick header on scroll | | stickyTop | string | '0px' | Top offset for sticky header (e.g. '64px' when a fixed navbar is present) | | loading | boolean | false | Show skeleton rows | | zebra | boolean | false | Alternate row background | | borders | boolean | false | Show column borders | | rowClass | string \| ((row, index) => string) | — | Extra class(es) for each row | | headerClass | string | — | Extra class for header cells |

| Event | Payload | Description | |---|---|---| | row-click | (row: object, index: number) | Emitted when a row is clicked |

Column props:

| Prop | Type | Description | |---|---|---| | field | string | Data field name | | header | string | Column header text | | class | string | Extra class for both header and body cells | | headerClass | string | Extra class for the header cell only | | bodyClass | string | Extra class for body cells only |

Column slots:

| Slot | Description | |---|---| | #body="{ data }" | Custom cell renderer — receives the row object | | #filter | Filter control rendered in the filter row. If any column defines #filter, a filter row is shown below the header row. |

DataTable slots:

| Slot | Description | |---|---| | header | Content above the column headers (spans full width) | | empty | Content shown when value is empty. Default: «Нет данных» |

Pagination

Page navigation with rows-per-page selector. Optionally syncs current page with a URL query param via the History API.

<template>
  <!-- v-model style (preferred) -->
  <Pagination :total-records="243" v-model:limit="limit" v-model:offset="offset" />

  <!-- legacy @refresh style (still supported) -->
  <Pagination :total-records="243" :limit="limit" :offset="offset" @refresh="onRefresh" />
</template>

<script setup>
import { ref } from 'vue'
import { Pagination } from 'cetera-vue-utils'

const limit = ref(20)
const offset = ref(0)

const onRefresh = ({ limit: l, offset: o }) => {
  limit.value = l
  offset.value = o
}
</script>

| Prop | Type | Default | Description | |---|---|---|---| | totalRecords | number | 0 | Total number of records | | limit | number | — | Records per page | | offset | number | — | Current offset | | limitOptions | number[] | [20, 50, 100] | Rows-per-page options | | queryParam | string | — | When set, syncs current page with this query param (queryParam="page"?page=2). Restores on mount, supports browser back/forward. |

v-model:limit and v-model:offset update automatically on page/rows-per-page change. The @refresh event emits { limit, offset } on each change and remains supported for backward compatibility.

Popover

Floating panel that attaches to an external trigger element. No wrapper slot — place the component next to or inside the target element.

<!-- target=true (default): attaches to the parent element -->
<template>
  <div>
    <Button label="Open" outlined />
    <Popover>
      <div class="p-3">Panel content</div>
    </Popover>
  </div>
</template>

<script setup>
import { Popover, Button } from 'cetera-vue-utils'
</script>

| Prop | Type | Default | Description | |---|---|---|---| | target | true \| false \| string \| HTMLElement | true | Trigger element. true = parent element, false = no binding (programmatic only), string = CSS selector ('#id', '.class'), HTMLElement = direct ref | | placement | 'auto' \| 'bottom-start' \| 'bottom-end' \| 'bottom' \| 'top-start' \| 'top-end' \| 'top' | 'auto' | Panel placement (ignored when contextMenu). auto picks the side with most available space | | contextMenu | boolean | false | Open on right-click at cursor position instead of click | | hover | boolean | false | Open on mouseenter, close on mouseleave (with 80 ms grace period to move into the panel) |

| Slot | Description | |---|---| | default | Panel content. Exposes { close } to close the panel programmatically |

| Method | Description | |---|---| | open(anchor?) | Programmatically open the panel. anchor can be a MouseEvent (positions at cursor) or HTMLElement (positions below element) |

Closes on Escape or click outside.

target by selector:

<Button id="my-btn" label="Open" outlined />
<Popover target="#my-btn">
  <div class="p-3">Content</div>
</Popover>

target by ref:

<button ref="btn">Open</button>
<Popover :target="btn">
  <div class="p-3">Content</div>
</Popover>

target=false — programmatic only:

<Button label="Open" @click="(e) => popover.open(e)" />
<Popover ref="popover" :target="false">
  <div class="p-3">Content</div>
</Popover>

contextmenu — panel opens at cursor:

<div class="p-8">
  Right-click here
  <Popover contextMenu>
    <Menu>
      <MenuItem @click="edit">Edit</MenuItem>
      <MenuItem @click="remove">Delete</MenuItem>
    </Menu>
  </Popover>
</div>

Composables

useNotify

Global notification system with auto-dismiss.

import { useNotify } from 'cetera-vue-utils'

const notify = useNotify()

notify.success('Saved!')
notify.error('Something went wrong')
notify.warn('Please check the input')
notify.info('Update available')

// Custom duration (ms)
notify.add('info', 'Custom message', 5000)

| Method | Type | Duration | |---|---|---| | success(message) | success | 3000ms | | info(message) | info | 3000ms | | warn(message) | warn | 4000ms | | error(message) | error | 5000ms | | add(type, message, life) | any | custom |

The notifications ref is shared across all useNotify() calls — readable from anywhere in the app.

useHttpClient

HTTP client based on axios with automatic error notifications and request cancellation support.

import { useHttpClient } from 'cetera-vue-utils'

const http = useHttpClient({
    baseURL: 'https://api.example.com',
    onUnauthorized: () => router.push('/login'),
})

// GET
const { data } = await http.get<User[]>('/users')

// POST
await http.post('/users', { data: { name: 'John' } })

// With params
await http.get('/users', { params: { page: 1 } })

// Cancel previous request on repeat call
await http.get('/search', { params: { q: 'text' }, cancelPrevious: true })

// Download file
await http.download('/reports/export.xlsx')

| Method | Description | |---|---| | get<T>(url, config?) | GET request | | post<T>(url, config?) | POST request | | patch<T>(url, config?) | PATCH request | | put<T>(url, config?) | PUT request | | destroy<T>(url, config?) | DELETE request | | download(url, config?) | Download file |

Automatic behavior:

  • 2xx with a message field — shows notify.success
  • 400, 5xx — shows notify.error
  • 422 — shows notify.error with validation message
  • 401 — calls onUnauthorized()
  • 403 — shows notify.error("Action not allowed!")
  • 419 — shows notify.error about expired session

useErrors

Validation error management for forms.

import { useErrors } from 'cetera-vue-utils'

const errors = useErrors()

// Record errors from server response
errors.record(response.errors)

// Check if a field has an error
errors.has('email')       // true / false

// Get error text for a field
errors.get('email')       // "Email is required"

// Check if any errors exist
errors.any()              // true / false

// Set a single error
errors.set({ path: 'email', value: 'Invalid format' })

// Clear one field or all fields
errors.clear('email')
errors.clear()

useDataLoad

Data loading via useHttpClient with managed loading and data state.

import { useDataLoad, useHttpClient } from 'cetera-vue-utils'

const http = useHttpClient({ baseURL: '/api' })

// Simple case
const { data, loading, load } = useDataLoad<User[]>(() => http.get('/users'))

// With params
const { data, meta, loading, load } = useDataLoad<User[], { page: number }>(
    (params) => http.get('/users', { params })
)

// Load
await load({ page: 1 })

// Load only if data is not yet fetched (cache)
await load(true)
await load({ page: 1 }, true)

// Share state between multiple loaders
const data = ref<User[]>()
const loading = ref(false)
const { load: loadUsers } = useDataLoad(() => http.get('/users'), { data, loading })
const { load: loadMore } = useDataLoad((p) => http.get('/users', { params: p }), { data, loading })

The returned meta contains total — useful for pagination.

useForm

Reactive form state with optional two-way URL query string sync.

import { useForm } from 'cetera-vue-utils'

// Basic usage
const form = useForm({
    defaults: { search: '', page: 1, active: true },
    onChange: () => load(),
})

// Sync form state to/from URL query params
const form = useForm({
    defaults: { search: '', page: 1 },
    syncQuery: true,
    onChange: () => load(),
})

| Option | Type | Default | Description | |---|---|---|---| | defaults | T | — | Initial form values | | onChange | () => void | — | Called on any change (deep watch) | | syncQuery | boolean | false | Sync form state to/from URL query string |

When syncQuery is enabled, form values are read from the URL on mount and written back on every change. Supports nested objects (filter[status]), dates, numbers, booleans, and arrays. Arrays are serialized as comma-separated values (e.g. ids=1,2,3) and deserialized back into arrays by checking whether the default value is an array. This works for both top-level array fields and array fields inside nested objects.

Returns a Ref<T> — use as form.value.fieldName or v-model="form.value.fieldName".

useFormElements

Helper composable for form components. Used internally by Input, but available standalone.

import { useFormElements } from 'cetera-vue-utils'

const { isInvalid, isHelper, uuid } = useFormElements(props)

TypeScript

All exports are fully typed:

import type {
  Notification,
  HttpResponse,
  UseHttpOptions,
  ErrorsData,
  InputProps,
  SpinnerSize,
} from 'cetera-vue-utils'

License

MIT