@okkly/vue
@okkly/vue
Vue component library for the Okryshto design system.
pnpm add @okkly/vue vue
Load the styles once at your entry point — the design system's tokens, fonts, and reset first, then this package's component CSS:
import "@okkly/design-system/styles/index.scss";
import "@okkly/vue/style.css";
@okkly/vue/style.css carries only the component rules; every colour, radius,
and duration in them resolves against the --okkly-* custom properties that
@okkly/design-system declares, so both imports are required.
<script setup lang="ts">
import { Button } from "@okkly/vue";
</script>
<template>
<Button variant="primary" @click="save">Click me</Button>
</template>
Button
Renders a native <button>, or an <a> when given an href. Props mirror
@okkly/react's <Button> name-for-name.
| Prop | Type | Default |
|---|---|---|
variant |
primary | gradient | secondary | soft | ghost | glass |
primary |
color |
primary | dante | indigo | violet | ember | ice |
primary |
shape |
pill | rounded |
pill |
size |
small | medium | large |
medium |
fullWidth |
boolean |
false |
loading |
boolean |
false |
loadingPosition |
start | center | end |
center |
disabled |
boolean |
false |
disableRipple |
boolean |
false |
href |
string |
undefined |
Icons are slots rather than props, since Vue has no ReactNode:
<Button variant="soft" size="large">
<template #start-icon><SaveIcon /></template>
Save
</Button>
An unfilled icon slot renders nothing at all, so no gap is reserved for an icon
you did not pass. loading implies disabled and swaps in a spinner — at the
start slot, the end slot, or centered over a dimmed label, per loadingPosition.
Anything the element itself understands — class, type, @click, aria-* —
falls through to the rendered <button>/<a>. A disabled <a> drops its href
and reports aria-disabled.
ButtonGroup
A split button: one main action, always visible, plus an optional chevron
menu of variants of that same action — MUI's "split button" recipe folded
into a single component. Props mirror @okkly/react's <ButtonGroup>
name-for-name: action/variant/menu/color/disabled/menuAriaLabel.
<ButtonGroup
:action="{ label: 'Save', onClick: save }"
:menu="[{ label: 'Save as…', onClick: saveAs }]"
/>
action.icon and each menu item's label narrow from React's ReactNode to
string — icon is raw SVG markup, rendered the way Icon's own icon prop
is, since action/menu are plain data props rather than something a slot
can reach into. For a row of independent toggle buttons, use
SegmentedToggle instead — this component only ever renders one action.
IconButton
Icon-only control for toolbars and dense UIs. Renders a native <button>, or
an <a> when given an href, exactly like Button. Props mirror
@okkly/react's <IconButton> name-for-name.
| Prop | Type | Default |
|---|---|---|
variant |
ghost | glass | solid |
ghost |
color |
primary | dante | indigo | violet | ember | ice |
primary |
size |
small | medium | large |
medium |
disabled |
boolean |
false |
disableRipple |
boolean |
false |
href |
string |
undefined |
<IconButton aria-label="Add">
<PlusIcon />
</IconButton>
React's icon prop and children fallback (icon ?? children) collapse
into a single default slot — the glyph, whichever way you pass it. Always
provide aria-label, since the slot is a bare icon with no visible text.
Anything else the element itself understands — class, @click, aria-* —
falls through to the rendered <button>/<a>. A disabled <a> drops its
href and reports aria-disabled.
Fab
Floating action button for a screen's primary create/navigate action. Renders
a native <button>, or an <a> when given an href, exactly like Button.
Props mirror @okkly/react's <Fab> name-for-name.
| Prop | Type | Default |
|---|---|---|
variant |
standard | soft |
standard |
color |
primary | dante | indigo | violet | ember | ice |
primary |
size |
small | medium | large |
medium |
disabled |
boolean |
false |
disableRipple |
boolean |
false |
href |
string |
undefined |
<Fab aria-label="Add">
<PlusIcon />
</Fab>
<Fab>
<PlusIcon />
<template #label>New track</template>
</Fab>
React's required icon prop becomes the default slot — the glyph, always
present. label becomes the label slot; filling it grows the FAB into an
extended pill instead of a plain circle, the same way React infers shape from
whether label is set rather than a separate enum value. Provide
aria-label when the label slot is empty. Anything else the element itself
understands — class, @click, aria-* — falls through to the rendered
<button>/<a>. A disabled <a> drops its href and reports
aria-disabled. No built-in SpeedDial — compose plain Fabs instead.
FileUpload
Drop zone for selecting files — click or drag & drop, with per-file
validation and a list of the current selection. Files that violate a
constraint are kept and marked instead of being dropped silently, so the
user can see what went wrong and remove them deliberately. Props mirror
@okkly/react's <FileUpload> name-for-name.
| Prop | Type | Default |
|---|---|---|
multiple |
boolean |
false |
accept |
FileType[] |
undefined |
maxSize |
number | BinaryPrefixedSize |
undefined |
maxTotalSize |
number | BinaryPrefixedSize |
undefined |
maxCount |
number |
undefined |
replace |
boolean |
false |
size |
large | medium | small |
large |
listType |
list | maxHeight | button | hidden |
list |
required |
boolean |
false |
error |
boolean |
undefined |
showError |
boolean |
undefined |
name |
string |
undefined |
disabled |
boolean |
false |
labels |
Partial<FileUploadLabels> |
undefined |
locale |
string |
undefined |
getFileStatus |
(file: File, index: number) => FileUploadStatus | undefined |
undefined |
fullWidth |
boolean |
false |
id |
string |
undefined |
<script setup lang="ts">
import { ref } from "vue";
import { FileUpload } from "@okkly/vue";
const files = ref<File[]>([]);
</script>
<template>
<FileUpload v-model="files" multiple max-size="10MiB">
<template #label>Attachments</template>
</FileUpload>
</template>
The controlled value/onChange pair becomes an unnamed
defineModel<File | File[] | null>(); v-model is always typed that way
regardless of multiple — React discriminates onChange's signature on a
generic type param, which defineProps can't express, so check multiple
yourself if the branch matters. label becomes the label slot. React's
renderFile render prop becomes the file scoped slot, receiving
{ file, index, status, disabled, remove }. onValidityChange becomes the
validity-change emit. inputRef is dropped — use defineExpose's own
inputRef (a template ref on the component instance) instead. There is no
Tooltip in @okkly/vue yet, so the size="small" error surfaces via the
native title attribute instead of a floating tooltip.
InlineAction
Compact text/icon control for table rows and cards — loading, success, and
error states without a full Button. state is the single source of truth
for the non-native visual states (loading/success/error/readonly/disabled);
disabled/loading/readonly are convenience overrides that win over
state when true (disabled > loading > readonly). Props mirror
@okkly/react's <InlineAction> name-for-name.
| Prop | Type | Default |
|---|---|---|
placeholder |
string |
undefined |
action |
string |
"Copy" |
size |
small | medium | large |
medium |
color |
primary | dante | indigo | violet | ember | ice | success | warning | danger |
undefined |
fill |
filled | soft | outline | gradient | glass |
filled |
message |
string |
undefined |
state |
default | hover | focus | filled | loading | success | error | readonly | disabled |
default |
readonly |
boolean |
false |
loading |
boolean |
false |
disabled |
boolean |
false |
id |
string |
undefined |
<script setup lang="ts">
import { ref } from "vue";
import { InlineAction } from "@okkly/vue";
const value = ref("");
</script>
<template>
<InlineAction v-model="value" placeholder="you@company.com" @action="copy" />
</template>
The controlled value/onChange pair becomes an unnamed defineModel<string>()
(this component has no uncontrolled defaultValue in React either, so there
is none here). actionIcon (ReactNode in React) becomes the action-icon
slot, shown only when state is default/hover/focus/filled/readonly
— it's overridden automatically for loading/success/error/readonly, same as
React. onAction becomes the action emit — a prop and an emit sharing a
name is fine in Vue, since props.action/emit("action") live in separate
namespaces. className is dropped — a consumer's class merges onto the
root automatically; every other native <input> attribute falls through to
the rendered <input> on its own.
NumberInput
Numeric text field with steppers. Prefer over TextField when min/max/step
matter and values stay numeric. Props follow MUI's TextField API where
applicable, mirroring @okkly/react's <NumberInput> name-for-name.
| Prop | Type | Default |
|---|---|---|
hideLabel |
boolean |
false |
size |
small | medium | large |
medium |
color |
primary | dante |
primary |
error |
boolean |
false |
fullWidth |
boolean |
false |
disabled |
boolean |
false |
controls |
stepper | chevrons |
stepper |
min |
number |
undefined |
max |
number |
undefined |
step |
number |
1 |
required |
boolean |
false |
id |
string |
undefined |
<script setup lang="ts">
import { ref } from "vue";
import { NumberInput } from "@okkly/vue";
const value = ref<number | null>(12);
</script>
<template>
<NumberInput v-model="value" :min="1" :max="99">
<template #label>Quantity</template>
</NumberInput>
</template>
label/helperText (both ReactNode in React) become the label/
helper-text slots. The controlled value/onChange pair becomes an
unnamed defineModel<number | null>(); defaultValue still seeds it once
on mount when nothing is bound. onBlur/onKeyDown are dropped — Vue
merges a consumer's @blur/@keydown with the component's own internal
listeners automatically (both run), unlike React where the component has to
manually call the prop back. className/id are dropped from that list —
Vue's own fallthrough (class) and useId() (id) handle them; every
other native <input> attribute falls through to the rendered <input> on
its own.
Rating
Star (or custom glyph) scale for scores. Support half-steps with
precision when the data allows it. Props follow MUI's Rating API
closely, mirroring @okkly/react's <Rating> name-for-name.
| Prop | Type | Default |
|---|---|---|
defaultValue |
number | null |
null |
max |
number |
5 |
precision |
0.5 | 1 |
0.5 |
size |
small | medium | large |
medium |
color |
warning | primary | dante | indigo | violet | ember | ice |
warning |
icon |
star | heart |
"star" |
readOnly |
boolean |
false |
disabled |
boolean |
false |
name |
string |
undefined |
getLabelText |
(value: number) => string |
— |
<script setup lang="ts">
import { ref } from "vue";
import { Rating } from "@okkly/vue";
const value = ref<number | null>(3);
</script>
<template>
<Rating v-model="value" />
</template>
The controlled value/onChange pair becomes an unnamed
defineModel<number | null>(); defaultValue still seeds it once on mount
when nothing is bound, and the event argument drops the same way it does
for Checkbox/Switch. icon narrows from React's RatingIcon | ReactNode
to just RatingIcon — pass a custom filled glyph through the icon slot
instead, which wins over the icon prop when filled (rendered the same way
for every star — full, half, and empty differ only by wrapping CSS, same as
the built-in star/heart). label (ReactNode in React) becomes the label
slot.
RichEditor
TipTap-based rich text with toolbar, slash menu, and word count. Prefer
TextArea for plain notes. Built on @tiptap/vue-3 instead of
@tiptap/react — the same framework-agnostic @tiptap/core/@tiptap/pm/
@tiptap/starter-kit/extension packages underneath. Props mirror
@okkly/react's <RichEditor> name-for-name.
| Prop | Type | Default |
|---|---|---|
format |
html | json |
"html" |
toolbar |
full | compact | none |
"full" |
placeholder |
string |
"Write something…" |
maxLength |
number |
undefined |
autosave |
number | false |
5000 |
slashMenu |
boolean |
true |
readonly |
boolean |
false |
disabled |
boolean |
false |
error |
boolean |
false |
fullWidth |
boolean |
true |
color |
primary | dante |
"primary" |
id |
string |
undefined |
<script setup lang="ts">
import { ref } from "vue";
import { RichEditor } from "@okkly/vue";
const value = ref("<p>Write something…</p>");
</script>
<template>
<RichEditor v-model="value">
<template #label>Description</template>
</RichEditor>
</template>
The controlled value/onChange pair becomes an unnamed
defineModel<RichEditorValue>(); defaultValue still seeds it once on
mount when nothing is bound. label/helperText (both ReactNode in
React) become the label/helper-text slots. className/id are dropped
— Vue's own fallthrough (class) and useId() (id) handle them.
SegmentedToggle
Exclusive segments in one control — view modes, filters, or short option
sets. Props follow MUI's ToggleButtonGroup API loosely, mirroring
@okkly/react's <SegmentedToggle> name-for-name.
| Prop | Type | Default |
|---|---|---|
items |
SegmentedToggleItem[] |
— |
defaultValue |
string | string[] |
undefined |
exclusive |
boolean |
true |
color |
primary | dante | indigo | violet | ember | ice |
"primary" |
disabled |
boolean |
false |
<script setup lang="ts">
import { ref } from "vue";
import { SegmentedToggle } from "@okkly/vue";
const value = ref("week");
const items = [
{ label: "Day", value: "day" },
{ label: "Week", value: "week" },
{ label: "Month", value: "month" },
];
</script>
<template>
<SegmentedToggle v-model="value" :items="items" />
</template>
The controlled value/onChange pair becomes an unnamed
defineModel<string | string[]>(); v-model is always typed that way
regardless of exclusive — React discriminates onChange's signature on
exclusive via a value that can be either shape, which defineProps can't
express either, so check exclusive yourself if the branch matters.
defaultValue still seeds it once on mount when nothing is bound. Each
item's label and icon (both ReactNode in React) narrow to string —
icon is raw SVG markup, rendered the way Icon's own icon prop is,
since items is a plain data prop rather than something a slot can reach
into. className is dropped — a consumer's class merges onto the root
automatically.
Checkbox
Binary or indeterminate choice. Props mirror @okkly/react's <Checkbox>
name-for-name.
| Prop | Type | Default |
|---|---|---|
value |
string |
undefined |
name |
string |
undefined |
indeterminate |
boolean |
false |
size |
small | medium | large |
medium |
color |
primary | dante | indigo | violet | ember | ice | success | warning | danger |
primary |
disabled |
boolean |
false |
id |
string |
auto-generated |
<script setup lang="ts">
import { ref } from "vue";
import { Checkbox } from "@okkly/vue";
const subscribed = ref(false);
</script>
<template>
<Checkbox v-model="subscribed">
<template #label>Subscribe to updates</template>
</Checkbox>
</template>
label is a slot rather than a prop, since Vue has no ReactNode, and renders
only when filled. The controlled checked state is v-model — standalone.
Nested inside a CheckboxGroup, value picks the option and the checked
state comes from the group instead; size/color also fall back through the
group when unset. Anything else the <input> itself understands (required,
aria-*, …) falls through to it — class is the one exception, which lands
on the outer <label> instead, matching React's className.
CheckboxGroup
Multi-select set of Checkbox children — nest them directly rather than
passing an options array, the same composition pattern as RadioGroup
(multi-select instead of single). Props mirror @okkly/react's
<CheckboxGroup> name-for-name: name/defaultValue/disabled/size/
color.
<script setup lang="ts">
import { ref } from "vue";
import { Checkbox, CheckboxGroup } from "@okkly/vue";
const channels = ref<string[]>(["email"]);
</script>
<template>
<CheckboxGroup v-model="channels">
<template #label>Notification channels</template>
<Checkbox value="email"><template #label>Email</template></Checkbox>
<Checkbox value="sms"><template #label>SMS</template></Checkbox>
</CheckboxGroup>
</template>
The controlled value/onChange pair becomes an unnamed defineModel<string[]>(),
so consumers can v-model it — defaultValue still seeds it (read in a
computed fallback, never written into the model on mount) when nothing is
bound. children becomes the default slot and label becomes the label
slot.
Switch
Immediate on/off toggle — prefer Checkbox for form "agree" statements that
submit later. Props mirror @okkly/react's <Switch> name-for-name.
| Prop | Type | Default |
|---|---|---|
size |
small | medium | large |
medium |
color |
primary | dante | indigo | violet | ember | ice |
primary |
disabled |
boolean |
false |
id |
string |
auto-generated |
<script setup lang="ts">
import { ref } from "vue";
import { Switch } from "@okkly/vue";
const notifications = ref(false);
</script>
<template>
<Switch v-model="notifications">
<template #label>Enable notifications</template>
</Switch>
</template>
label is a slot rather than a prop, since Vue has no ReactNode, and renders
only when filled. The controlled checked state is v-model. Anything else
the <input> itself understands (value, name, required, aria-*,
@change…) falls through to it — class is the one exception, which lands
on the outer <label> instead, matching React's className. The rendered
<input type="checkbox" role="switch"> carries the switch role for assistive
technology.
Radio / RadioGroup
Radio is a bare control — nest it inside RadioGroup for exclusive choice
among options, matching MUI's RadioGroup composition pattern. Props mirror
@okkly/react's <Radio> / <RadioGroup> name-for-name.
Radio prop |
Type | Default |
|---|---|---|
checked |
boolean |
undefined |
value |
string |
undefined |
name |
string |
undefined |
size |
small | medium | large |
undefined |
color |
primary | dante | indigo | violet | ember | ice |
undefined |
disabled |
boolean |
false |
id |
string |
auto-generated |
RadioGroup prop |
Type | Default |
|---|---|---|
name |
string |
auto-generated |
defaultValue |
string |
undefined |
disabled |
boolean |
false |
size |
small | medium | large |
medium |
color |
primary | dante | indigo | violet | ember | ice |
primary |
<script setup lang="ts">
import { ref } from "vue";
import { Radio, RadioGroup } from "@okkly/vue";
const preference = ref("email");
</script>
<template>
<RadioGroup v-model="preference">
<template #label>Notification preference</template>
<Radio value="email"><template #label>Email</template></Radio>
<Radio value="sms"><template #label>SMS</template></Radio>
</RadioGroup>
</template>
RadioGroup shares its selection with nested Radios through provide/
inject (the Vue equivalent of React's context) — no options array, just
nest <Radio value="..."> directly. The controlled value + onChange pair
becomes v-model; defaultValue still seeds it once on mount when nothing is
bound, same as React's uncontrolled mode. size/color set on RadioGroup
apply to every nested Radio unless one sets its own. label is a slot on
both components, rendering only when filled; on RadioGroup it also becomes
the visible group heading above the options (there's no automatic
aria-label derived from it, unlike React — pass your own aria-label when
the visible label isn't plain text). Radio's own checked stays a plain
prop paired with the change emit rather than v-model, since a grouped
radio's checked state is derived from the group rather than owned locally.
Chip / ChipGroup
Chip is a compact filter, tag, or choice token; ChipGroup composes them
into a wrapping row with single- or multi-select. Props mirror
@okkly/react's <Chip> / <ChipGroup> name-for-name.
Chip prop |
Type | Default |
|---|---|---|
variant |
glass | solid | outline | accent | dante |
glass |
size |
small | medium | large |
medium |
selected |
boolean |
false |
dot |
boolean |
false |
removable |
boolean |
false |
disabled |
boolean |
false |
removeLabel |
string |
"Remove" |
ChipGroup prop |
Type | Default |
|---|---|---|
items |
ChipGroupItem[] |
undefined |
exclusive |
boolean |
false |
color |
primary | dante | indigo | violet | ember | ice |
primary |
disabled |
boolean |
false |
<script setup lang="ts">
import { ref } from "vue";
import { ChipGroup } from "@okkly/vue";
const filters = ref<string[]>(["design"]);
const items = [
{ label: "Design", value: "design" },
{ label: "Engineering", value: "engineering" },
];
</script>
<template>
<ChipGroup v-model="filters" :items="items" />
</template>
Chip's label becomes the default slot (its only primary content, same
mapping as Button's children) and icon becomes the icon slot, since
Vue has no ReactNode. Chip's click listener — bound with @click, same
as any native element — is read by the component itself rather than left to
a plain fallthrough: only that lets it decide role="button"/keyboard
activation from whether a listener is attached at all, and gate it in JS
when disabled. onRemove becomes the remove emit.
ChipGroup's controlled value + onChange pair becomes an unnamed
v-model, typed string | string[] depending on exclusive; children
becomes the default slot, the escape hatch for a fully custom chip tree used
when items is omitted. ChipGroupItem.label is a plain string rather
than ReactNode, since a data array has no natural place for slot content;
its onClick/onRemove stay plain callback props exactly as in React, since
they live inside a data object rather than being props of a component
instance.
Slider
Continuous or discrete value along a track, with single or range (two-thumb)
selection. Props mirror @okkly/react's <Slider> name-for-name.
| Prop | Type | Default |
|---|---|---|
min |
number |
0 |
max |
number |
100 |
step |
number |
1 |
marks |
boolean | { value: number; label?: string }[] |
false |
orientation |
horizontal | vertical |
horizontal |
disabled |
boolean |
false |
color |
primary | dante | indigo | violet | ember | ice |
primary |
size |
small | medium | large |
medium |
valueLabelDisplay |
auto | on | off |
off |
discrete |
boolean |
false |
shiftStep |
number |
undefined |
getAriaLabel |
(index: number) => string |
undefined |
getAriaValueText |
(value: number, index: number) => string |
undefined |
track |
normal | inverted | none |
normal |
valueLabelFormat |
(value: number, index: number) => string |
String |
<script setup lang="ts">
import { ref } from "vue";
import { Slider } from "@okkly/vue";
const brightness = ref(30);
</script>
<template>
<Slider v-model="brightness" value-label-display="auto" aria-label="Brightness" />
</template>
The controlled value + onChange pair becomes an unnamed v-model, typed
number | number[] — pass an array to get a two-thumb range slider.
defaultValue still seeds it once on mount when nothing is bound, same as
React's uncontrolled mode; unlike RadioGroup, this never happens through an
eager write to the model (which would itself emit update:modelValue on
mount) — the fallback lives in the read path instead, so nothing fires until
the user actually moves a thumb, matching React's lazy initializer.
onChangeCommitted becomes the changeCommitted emit, firing once a drag or
keypress settles rather than on every intermediate value. Both drop the
(event, …) pair down to just the value, since nothing native fires a real
event here. The headless pointer/keyboard/ARIA behavior underneath is
useSlider from @okkly/vue-composables, the Vue port of
@okkly/react-hooks's hook of the same name.
TextField
Single-line text input with label, helper text, and error state — the
foundation most form fields build on. Props mirror @okkly/react's
<TextField> name-for-name.
| Prop | Type | Default |
|---|---|---|
hideLabel |
boolean |
false |
size |
small | medium | large |
medium |
color |
primary | secondary | dante | violet | ember | ice | contrast |
primary |
error |
boolean |
false |
fullWidth |
boolean |
false |
required |
boolean |
false |
disabled |
boolean |
false |
id |
string |
auto-generated |
<script setup lang="ts">
import { ref } from "vue";
import { TextField } from "@okkly/vue";
const email = ref("");
</script>
<template>
<TextField v-model="email" required>
<template #label>Email</template>
<template #helper-text>We'll never share it</template>
</TextField>
</template>
label, helper-text, start-adornment and end-adornment are slots rather
than props, since Vue has no ReactNode; each renders only when filled. The
controlled value is v-model. Anything else the <input> itself understands
(type, placeholder, name, maxlength, @input, @change…) falls
through to it — class is the one exception, which lands on the outer field
wrapper instead, matching React's className.
color tints the focus ring/glow — dante is a rare, deliberate accent
moment; the rest are for matching a field to surrounding brand/section color.
TextArea
Multi-line text field for notes and descriptions. Prefer RichEditor when
formatting is required. Props mirror @okkly/react's <TextArea>
name-for-name.
| Prop | Type | Default |
|---|---|---|
hideLabel |
boolean |
false |
size |
small | medium | large |
medium |
color |
primary | dante |
primary |
error |
boolean |
false |
fullWidth |
boolean |
false |
disabled |
boolean |
false |
rows |
number |
3 |
maxRows |
number |
undefined |
autosize |
boolean |
false |
maxLength |
number |
undefined |
resize |
none | vertical | both |
vertical |
required |
boolean |
false |
id |
string |
auto-generated |
<script setup lang="ts">
import { ref } from "vue";
import { TextArea } from "@okkly/vue";
const message = ref("");
</script>
<template>
<TextArea v-model="message" required>
<template #label>Message</template>
<template #helper-text>Markdown supported</template>
</TextArea>
</template>
label and helper-text are slots rather than props, since Vue has no
ReactNode; each renders only when filled. The controlled value is
v-model. Anything else the <textarea> itself understands (placeholder,
name, @input, @change…) falls through to it — class is the one
exception, which lands on the outer wrapper instead, matching React's
className.
Set maxLength to show an "n / max" character counter next to the helper
text. autosize grows the field with its content up to maxRows; resize
controls the manual resize handle and is ignored while autosize is on.
Spinner
An indeterminate loading ring. Props mirror @okkly/react's <Spinner>
name-for-name: size/color/thickness.
<Spinner size="small" color="primary" />
Option primitives
OptionScope, OptionRow, OptionLabel, OptionDescription, OptionBody,
OptionCheck and HighlightMatch — the building blocks a custom #option
slot (see Autocomplete below) is built from, mirroring @okkly/react's
Option.tsx exports name-for-name. OptionScope names the BEM block
("okkly-autocomplete", and later "okkly-select") the parts underneath it
read, so a custom row still picks up its listbox's styling; outside a scope
they render as plain, unstyled elements.
Autocomplete
Text field with a filtered suggestions list — continuous typing, multi-select
tags, grouping and free solo. Props follow @okkly/react's <Autocomplete>
name-for-name where Vue lets it; see the doc comment on AutocompleteProps in
Autocomplete.types.ts for the full list of Vue-forced differences. The
highlights:
- The controlled
value/onChangepair becomes the primaryv-model;inputValue/onInputChangeandopen/onOpenChangebecome the named modelsv-model:input-valueandv-model:open. Achangeemit still carries MUI's full(event, value, reason, details)signature for callers that need the reason a plainv-modeldrops. renderOption/renderInput/renderGroup/renderNoOptions/renderLoading/renderTagsbecome the scoped slots#option,#input,#group,#no-options,#loadingand#tags.#optionand#inputare narrower than React's render props — Vue's template model has no equivalent for "hand over pre-rendered content to reposition" without dropping to manual render functions, so#optionreplaces a whole row (full parity) but#grouponly replaces the header (the row list stays fixed) and#inputonly replaces the<input>(the tag row and clear/toggle buttons stay fixed).
<script setup lang="ts">
import { ref } from "vue";
import { Autocomplete } from "@okkly/vue";
import type { AutocompleteOption } from "@okkly/vue";
const options: AutocompleteOption[] = [
{ value: "paris", label: "Paris" },
{ value: "tokyo", label: "Tokyo" },
];
const city = ref<AutocompleteOption | null>(null);
</script>
<template>
<Autocomplete v-model="city" :options="options">
<template #label>City</template>
</Autocomplete>
</template>
It's built on the new useAutocomplete composable from
@okkly/vue-composables, itself built on the new useControllableState
composable — see that package's README for both.
Select
Closed list of options in a field — prefer Autocomplete when the list is
long or searchable. Props follow @okkly/react's <Select> (and through it
MUI's Select) name-for-name where Vue lets it; see the doc comment on
SelectProps in Select.types.ts for the full list of Vue-forced
differences. The highlights:
- The controlled
value/onChangepair becomes the primaryv-model;open/onOpenChangebecomes the named modelv-model:open. Achangeemit still carries MUI's full(event, value, reason, details)signature for callers that need the reason a plainv-modeldrops. renderOption/renderInput/renderValue/renderGroup/renderNoOptions/renderLoadingbecome the scoped slots#option,#trigger,#value,#group,#no-optionsand#loading.#triggeris named for what it rebuilds — adiv[role="combobox"]trigger, not a text<input>— rather than reusingAutocomplete's own#inputname.#groupis narrower than React'srenderGroup: it replaces only the header, since Vue's template model has no equivalent for "hand over pre-rendered content to reposition" without dropping to manual render functions.
<script setup lang="ts">
import { ref } from "vue";
import { Select } from "@okkly/vue";
import type { SelectOption } from "@okkly/vue";
const options: SelectOption[] = [
{ value: "design", label: "Product design" },
{ value: "engineering", label: "Engineering" },
];
const team = ref<string | null>(null);
</script>
<template>
<Select v-model="team" :options="options">
<template #label>Team</template>
</Select>
</template>
It's built on the new useSelect composable from @okkly/vue-composables,
which reuses the same grouping/normalization utilities useAutocomplete
built — see that package's README for both.
Icon
Renders any glyph from @okkly/icons, painted with currentColor so it
inherits the surrounding text colour by default. Props mirror
@okkly/react's <Icon> name-for-name: name/icon/color/fontSize/
titleAccess.
<Icon name="iconStar" />
<Icon :icon="iconHeart" color="danger" font-size="large" />
Give it name (autocompleted from the full set) or icon (markup you
already imported, for a tree-shakeable bundle) — React encodes "exactly one
of the two" as a discriminated union; Vue's defineProps can't express that
XOR, so both stay plain optional props and neither being set renders empty.
ICON_NAMES is exported alongside the component, the sorted list of every
available name.
Typography
Text primitive for the editorial type scale, from display-2xl down to
mono-sm. Props mirror @okkly/react's <Typography> name-for-name:
variant/color/align/gutterBottom/noWrap/as.
<Typography variant="h1" as="div">Looks like a page title, renders a div</Typography>
as takes a tag name or a component, the same shape Box's own as
already uses, rather than React's per-element prop inference (as="a"
accepting exactly href) — Vue has no equivalent of
ComponentPropsWithoutRef<E> for a runtime-resolved :is, so whatever the
chosen element accepts falls through unchecked. TYPOGRAPHY_VARIANTS (the
variant → default-tag map) is exported alongside the component, same as
@okkly/react.
Avatar
The person, compressed to one glyph: an image when there's one, initials
when there isn't, falling back to initials on its own if the image fails to
load. Props mirror @okkly/react's <Avatar> name-for-name: src/alt/
initials/status/shape/size/color.
<Avatar src="/oleksii.jpg" alt="Oleksii Kryshtopa" status="online" />
<Avatar initials="OK" color="dante" />
AvatarGroup
A stack of overlapping Avatar children for "who is on this", collapsing
anything past max into a "+N" chip. Props mirror @okkly/react's
<AvatarGroup> name-for-name: max/total/size/spacing/ring/hues.
<AvatarGroup :max="4" :hues="['mint', 'dante', 'indigo']">
<Avatar v-for="member in team" :key="member.id" :initials="member.initials" />
</AvatarGroup>
It reads its Avatar children from the default slot and overrides each
one's size/color with Vue's cloneVNode — the same transparent-override
contract React's version gets from cloneElement. One Vue-specific wrinkle
this had to account for: a v-for inside a slot compiles to a single
Fragment vnode wrapping the repeated children rather than N sibling
vnodes the way {list.map(...)} already is in JSX, so the children are
flattened before counting/slicing them.
Divider
Hairline separator for lists, stacks and toolbars, with an optional
centered (or aligned) label. Props mirror @okkly/react's <Divider>
name-for-name: orientation/flexItem/textAlign/variant; the label is
the default slot.
<Divider>or</Divider>
<Divider orientation="vertical" flex-item />
Calendar
Month grid for a single date or a start/end range, with a year → month → day
drill-down header. Props mirror @okkly/react's <Calendar> name-for-name:
mode/min/max/weekStart/locale/previousMonthLabel/nextMonthLabel/
color.
<script setup lang="ts">
import { ref } from "vue";
import { Calendar } from "@okkly/vue";
const value = ref<Date | null>(null);
</script>
<template>
<Calendar v-model="value" />
</template>
The controlled value/onSelect pair becomes the primary v-model;
month/onMonthChange becomes the named model v-model:month. v-model is
always typed Date | [Date, Date] | null regardless of mode — React
discriminates onSelect's signature on mode via a union prop type, which
defineProps can't express, so check mode yourself if the branch matters.
calendarToneStyle (the color → --okkly-calendar-tone mapping) is
exported alongside the component, same as React, for DateField-style
consumers once those are ported.
TimePicker
Scrollable hour/minute (and, for format="12h", AM/PM) wheels — a plain,
MUI MultiSectionDigitalClock-style column list, not a masked text input.
Props mirror @okkly/react's <TimePicker> name-for-name:
step/format/color/hoursAriaLabel/minutesAriaLabel/
meridiemAriaLabel.
<script setup lang="ts">
import { ref } from "vue";
import { TimePicker } from "@okkly/vue";
import type { TimePickerValue } from "@okkly/vue";
const value = ref<TimePickerValue>({ h: 9, m: 30 });
</script>
<template>
<TimePicker v-model="value" format="12h" />
</template>
The controlled value/onChange pair becomes an unnamed defineModel, so
consumers v-model it; defaultValue still seeds it once on mount when
nothing is bound. value.h is always canonical 24-hour (0–23) regardless of
format — the AM/PM wheel is purely a 12-hour selection helper layered on
top of it.
DateTimePicker
A fixed inline card composed from Calendar + TimePicker — pick a day on
the left, dial in a time on the right, then Confirm. No MUI equivalent as a
fixed surface (MUI X's DateTimePicker is a masked text input with a
popover); there's no shortcut-preset sidebar either — a preset is one line
against v-model. Props mirror @okkly/react's <DateTimePicker>
name-for-name: min/max/timeStep/format/weekStart/color/locale/
previousMonthLabel/nextMonthLabel.
<script setup lang="ts">
import { ref } from "vue";
import { DateTimePicker } from "@okkly/vue";
const value = ref<Date | null>(null);
</script>
<template>
<DateTimePicker v-model="value" @confirm="onConfirm">
<template #timezone-label>GMT+2</template>
</DateTimePicker>
</template>
The controlled value/onChange pair becomes an unnamed defineModel;
onConfirm becomes the confirm emit, firing with the current value when
Confirm is clicked (disabled until a day is picked). timezoneLabel/
summaryLabel/emptyLabel/confirmLabel (all ReactNode in React) become
the timezone-label/summary-label/empty-label/confirm-label slots —
each falls back to React's own default text when left unfilled, and the
timezone chip renders only when that slot is filled.
DateField
Masked dd.mm.yyyy text input with a Calendar popover, built on
@maskito/vue's v-maskito directive. Closest MUI counterpart is MUI X's
DateField/DatePicker. Props mirror @okkly/react's <DateField>
name-for-name: size/color/error/fullWidth/disabled/min/max/
placeholder/id/required.
<script setup lang="ts">
import { ref } from "vue";
import { DateField } from "@okkly/vue";
const value = ref<Date | null>(null);
</script>
<template>
<DateField v-model="value">
<template #label>Date</template>
</DateField>
</template>
label/helperText (both ReactNode in React) become the label/
helper-text slots. The controlled value/onChange pair becomes an
unnamed defineModel<Date | null>(); the controlled open/onOpenChange
pair becomes the named defineModel<boolean>("open"). className is
dropped — a consumer's class merges onto the field automatically.
TimeField
Masked HH:mm text input with a TimePicker popover, built on
@maskito/vue's v-maskito directive. Closest MUI counterpart is MUI X's
TimeField/TimePicker. Props mirror @okkly/react's <TimeField>
name-for-name: size/color/error/fullWidth/disabled/placeholder/
id/required.
<script setup lang="ts">
import { ref } from "vue";
import { TimeField } from "@okkly/vue";
const value = ref<Date | null>(null);
</script>
<template>
<TimeField v-model="value">
<template #label>Time</template>
</TimeField>
</template>
Same slot/model mapping as DateField: label/helperText become the
label/helper-text slots, the controlled value/onChange pair becomes
an unnamed defineModel<Date | null>() (time-of-day on a fixed base day),
and open/onOpenChange becomes the named defineModel<boolean>("open").
DateTimeField
Masked dd.mm.yyyy, HH:mm text input with a DateTimePicker popover, built
on @maskito/vue's v-maskito directive; the picker closes on its own
Confirm click. Closest MUI counterpart is MUI X's
DateTimeField/DateTimePicker. Props mirror @okkly/react's
<DateTimeField> name-for-name: size/color/error/fullWidth/
disabled/min/max/placeholder/id/required.
<script setup lang="ts">
import { ref } from "vue";
import { DateTimeField } from "@okkly/vue";
const value = ref<Date | null>(null);
</script>
<template>
<DateTimeField v-model="value">
<template #label>Date & time</template>
</DateTimeField>
</template>
Same slot/model mapping as DateField/TimeField: label/helperText
become the label/helper-text slots, the controlled value/onChange
pair becomes an unnamed defineModel<Date | null>(), and
open/onOpenChange becomes the named defineModel<boolean>("open"). The
input updates live as the popover's date/time wheels move; it also updates
(and the popover closes) when the popover's own Confirm button is clicked.
Box
The layout primitive: a div — or any element, through as — that takes
MUI-style system props. Props mirror @okkly/react's <Box> name-for-name and
resolve through the same @okkly/shared function, so both render the same DOM.
<Box
as="section"
display="flex"
:flex-direction="{ base: 'column', md: 'row' }"
:gap="4"
:p="{ base: 3, md: 6 }"
bgcolor="bg.surface-raised"
:border-radius="3"
>
…
</Box>
| Props | Value |
|---|---|
m mx my mt mr mb ml, p px py pt pr pb pl, gap rowGap columnGap, borderRadius |
A step on the 4px scale (2 → 8px) or any CSS length |
display flexDirection flexWrap alignItems justifyContent alignSelf flexGrow flexShrink |
The CSS value |
flexBasis width height minWidth maxWidth minHeight maxHeight |
A fraction up to 1 is a percentage, a larger number pixels, a string CSS |
bgcolor color borderColor |
A token path (bg.surface, text.secondary, border.strong…) or any CSS color |
border |
A width in pixels, drawn in the default border color, or the CSS shorthand |
as |
A tag name or a component — div by default |
container |
Makes the Box a query container for its descendants' @-keys |
Every system prop takes one value per breakpoint: base at every width, the
viewport breakpoints 2xs … xl from that window width up, and the container
breakpoints @xs (320px) … @xl (1024px) from that width of the nearest
container Box up — container values win. class, style and every other
attribute fall through to the element, and a consumer's class/style merge
after Box's own.
Popper
Positioning and nothing else — puts its default slot next to anchorEl and
keeps it there through scrolling, resizing and the edges of the viewport, via
@popperjs/core. It draws no surface and owns no dismissal; it is
the engine Popover is built on. Props mirror @okkly/react's <Popper>
name-for-name.
<script setup lang="ts">
import { ref } from "vue";
import { Popper } from "@okkly/vue";
const anchorEl = ref<HTMLElement | null>(null);
</script>
<template>
<button ref="anchorEl" type="button">Anchor</button>
<Popper :open="!!anchorEl" :anchor-el="anchorEl" placement="bottom-start">
<div class="my-panel">Positioned, and nothing else.</div>
</Popper>
</template>
| Prop | Type | Default |
|---|---|---|
open |
boolean |
— |
anchorEl |
PopperAnchorEl |
undefined |
placement |
PopperPlacement |
"bottom" |
keepMounted |
boolean |
false |
disablePortal |
boolean |
false |
container |
Element | DocumentFragment | null |
undefined |
modifiers |
Array<Partial<Modifier<…>>> |
undefined |
popperOptions |
Partial<Options> |
undefined |
transition |
boolean |
false |
matchAnchorWidth |
boolean | "min" |
false |
minWidth |
number | string |
undefined |
role |
string |
"tooltip" |
children, including React's render-prop form, becomes the default slot,
scoped with { placement, transitionProps }: placement is what Popper.js
actually resolved to, and transitionProps (present only when transition
is set) is { in, onEnter, onExited } — wire it into a <Transition> so
Popper stays mounted for the whole exit. popperRef is dropped; put a
template ref on <Popper> and read its exposed popperInstance instead.
Popover
Popper plus dismissal: Escape, click-outside, a surface, and a scale+fade
transition. It is not modal and has no scrim by default —
hideBackdrop starts at true. Props mirror @okkly/react's <Popover>
name-for-name; onClose becomes the close emit, still carrying
(event, reason).
<script setup lang="ts">
import { ref } from "vue";
import { Popover } from "@okkly/vue";
const anchorEl = ref<HTMLElement | null>(null);
</script>
<template>
<button @click="anchorEl = $event.currentTarget">Open</button>
<Popover :open="!!anchorEl" :anchor-el="anchorEl" @close="anchorEl = null">
<div>A menu, a filter panel, a date picker — anything that belongs to a control.</div>
</Popover>
</template>
| Prop | Type | Default |
|---|---|---|
open |
boolean |
— |
anchorEl |
HTMLElement | null |
undefined |
anchorPosition |
{ top: number; left: number } |
undefined |
placement |
PopperPlacement |
"bottom" |
transitionDuration |
number | { enter?; exit? } | "auto" |
"auto" |
disablePortal |
boolean |
false |
hideBackdrop |
boolean |
true |
matchAnchorWidth |
boolean |
false |
minWidth |
number | string |
undefined |
paperClassName |
string |
undefined |
Modal
The low-level primitive every modal overlay is built from: a portal, a
backdrop, a trapped focus, a body scroll lock, Escape handling, and focus
restored on close. It draws no surface of its own — the default slot
supplies all visual chrome. Props mirror @okkly/react's <Modal>
name-for-name; onClose becomes the close emit.
<script setup lang="ts">
import { ref } from "vue";
import { Modal } from "@okkly/vue";
const open = ref(false);
</script>
<template>
<button @click="open = true">Open</button>
<Modal :open="open" @close="open = false">
<div class="my-dialog" role="dialog" aria-modal="true" aria-label="Example">
Your own panel, centred and styled by you.
</div>
</Modal>
</template>
| Prop | Type | Default |
|---|---|---|
open |
boolean |
— |
container |
Element | null |
undefined |
disablePortal |
boolean |
false |
disableEscapeKeyDown |
boolean |
false |
disableAutoFocus |
boolean |
false |
disableEnforceFocus |
boolean |
false |
disableRestoreFocus |
boolean |
false |
disableScrollLock |
boolean |
false |
hideBackdrop |
boolean |
false |
keepMounted |
boolean |
false |
backdropClass |
string |
undefined |
MUI's slotProps.backdrop escape hatch narrows to backdropClass — a single
extra class for restyling the backdrop. Anything a backdrop click should
additionally do belongs in the close handler, which already sees the
"backdropClick" reason.
Dialog
A centred panel that interrupts — built on Modal, which owns the portal,
backdrop, focus trap, scroll lock and focus restoration. Dialog adds the
centring and the sized paper, opening it with a Grow transition, and ships
DialogTitle / DialogContent / DialogActions / DialogClose for the
parts. Props mirror @okkly/react's <Dialog> name-for-name and inherit
every Modal prop; onClose becomes the close emit, carrying
(event, reason) so a stray backdrop click can be told apart from a
deliberate Escape.
<script setup lang="ts">
import { ref } from "vue";
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@okkly/vue";
const open = ref(false);
const close = () => {
open.value = false;
};
</script>
<template>
<Button @click="open = true">Delete the project</Button>
<Dialog :open="open" max-width="xs" @close="close">
<DialogTitle>Delete “Night drive”?</DialogTitle>
<DialogContent>This cannot be undone.</DialogContent>
<DialogActions>
<Button variant="ghost" @click="close">Keep it</Button>
<Button color="ember" @click="close">Delete it</Button>
</DialogActions>
</Dialog>
</template>
| Prop | Type | Default |
|---|---|---|
fullWidth |
boolean |
false |
maxWidth |
"xs" | "sm" | "md" | "lg" | "xl" | false |
"sm" |
fullScreen |
boolean |
false |
transitionDuration |
number | { enter?, exit? } | "auto" |
"auto" |
Every Modal prop above (container, keepMounted, disableEscapeKeyDown,
…) is inherited and forwarded. DialogClose renders the corner ✕ — pass it
its own @click handler, since closing stays the caller's job.
Transitions
Fade, Grow, Zoom, Slide and Collapse mirror @okkly/react's
transition family, which in turn follows MUI's. Each takes a single default
slot and an in boolean (yes, in — it is only a reserved word as a bare
identifier, not as a prop name, so :in="open" works), and animates it:
| Component | Animates | Default use |
|---|---|---|
Fade |
opacity | present or not, with no directional cue |
Grow |
scale + opacity, from 75% | something arriving from a trigger — Popover is built on this |
Zoom |
scale, from nothing | a floating action button, a badge popping in |
Slide |
translate along one edge | a drawer, a toast, a bottom sheet |
Collapse |
height (or width) | an accordion panel, "show more" |
<script setup lang="ts">
import { ref } from "vue";
import { Fade } from "@okkly/vue";
const open = ref(false);
</script>
<template>
<button @click="open = !open">Toggle</button>
<Fade :in="open">
<div class="my-panel">…</div>
</Fade>
</template>
Shared props (in, appear, easing, and — Fade/Grow/Zoom/Slide only —
keepMounted) are documented on SharedTransitionProps. Two differences from
React's family, both forced by Vue having no cloneElement:
- Each component owns one wrapper element around the slot, rather than
writing its class/style directly onto the caller's own child — the only way
to reach that child reliably is through Vue's
<Transition>hooks, and a bare<slot>cannot carryv-show(or any directive) forkeepMountedto use. mountOnEnter/unmountOnExit— react-transition-group's two independent knobs — collapse into the onekeepMountedboolean:false(the default) removes the child from the DOM once hidden (v-if);truekeeps it, hidden withdisplay: none(v-show), which is Vue's native way to do what React does withvisibility: hiddenwhile staying mounted.
Collapse is the exception: it always owns a wrapper, so its content is
mounted by default (unmountOnExit, off by default, opts into removing it
once fully collapsed) and it has no keepMounted prop.
Every component's six React lifecycle callbacks (onEnter/onEntering/
onEntered/onExit/onExiting/onExited) narrow to four emits — enter,
entered, exit, exited — since Vue's <Transition> exposes one hook per
phase rather than react-transition-group's three-part state machine.
addEndListener, an RTG escape hatch, has no Vue equivalent and is
Version History
| Version | Released | License |
|---|---|---|
| 0.4.1 | MIT | |
| 0.4.0 | MIT | |
| 0.3.0 | MIT | |
| 0.2.1 | MIT | |
| 0.2.0 | MIT | |
| 0.1.2 | MIT | |
| 0.1.1 | MIT | |
| 0.1.0 | MIT |