Merge pull request #141 from robonen/docs

feat(forms): add useMaskedField and useMaskedInput composables for in…
This commit is contained in:
2026-06-09 13:58:53 +07:00
committed by GitHub
426 changed files with 12981 additions and 311 deletions
+1
View File
@@ -70,6 +70,7 @@
},
"dependencies": {
"@floating-ui/vue": "^1.1.11",
"@robonen/encoding": "workspace:*",
"@robonen/platform": "workspace:*",
"@robonen/stdlib": "workspace:*",
"@robonen/vue": "workspace:*",
@@ -1,6 +1,12 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* The collapsible panel revealed when its item is open. Rendered as an ARIA
* `region` labelled by its trigger and mounted/unmounted via `Presence` so
* enter/leave transitions can run (use `forceMount` to keep it mounted for
* custom animation).
*/
export interface AccordionContentProps extends PrimitiveProps {
/** Keep content mounted even when closed. */
forceMount?: boolean;
@@ -1,6 +1,12 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* A single collapsible section of the accordion, grouping one trigger with
* its content. Identified by a unique `value` that the root uses to track
* open state; provides item-level context (open, disabled, ids) to its
* `AccordionTrigger` and `AccordionContent`.
*/
export interface AccordionItemProps extends PrimitiveProps {
/** Unique value for this item. */
value: string;
+20 -24
View File
@@ -2,10 +2,16 @@
import type { PrimitiveProps } from '../primitive';
import type { RovingDirection } from '../utils/roving-focus';
/**
* A vertically (or horizontally) stacked set of headers that each reveal an
* associated panel of content. Use it to let users expand and collapse
* sections to manage information density — FAQs, settings groups, or any
* place a `Collapsible` per item would be repetitive.
*
* The root owns open state (single or multiple panels), keyboard roving
* focus across triggers, and provides context to every `AccordionItem`.
*/
export interface AccordionRootProps extends PrimitiveProps {
/** Current open value(s) for controlled mode. */
modelValue?: string | string[];
/** Initial value(s) for uncontrolled mode. */
defaultValue?: string | string[];
@@ -30,7 +36,7 @@ export interface AccordionRootProps extends PrimitiveProps {
</script>
<script setup lang="ts">
import { computed, shallowRef, toRef, watch } from 'vue';
import { computed, ref, toRef } from 'vue';
import { resolveNextIndex, rovingKeyToAction } from '../utils/roving-focus';
import { Primitive } from '../primitive';
import { provideAccordionContext } from './context';
@@ -45,34 +51,25 @@ const {
orientation = 'vertical',
dir = 'ltr',
loop = true,
modelValue,
defaultValue,
as = 'div',
} = defineProps<AccordionRootProps>();
const { forwardRef } = useForwardExpose();
const emit = defineEmits<{ 'update:modelValue': [value: string | string[] | undefined] }>();
type RovingAction = NonNullable<ReturnType<typeof rovingKeyToAction>>;
const openSet = shallowRef<Set<string>>(
new Set(toArray(modelValue ?? defaultValue)),
);
const localValue = ref<string | string[] | undefined>(defaultValue);
function setEqualsArray(set: Set<string>, arr: string[]): boolean {
if (arr.length !== set.size) return false;
for (let i = 0; i < arr.length; i++) if (!set.has(arr[i]!)) return false;
return true;
}
watch(() => modelValue, (v) => {
if (v === undefined) return;
const arr = toArray(v);
if (setEqualsArray(openSet.value, arr)) return;
openSet.value = new Set(arr);
const model = defineModel<string | string[] | undefined>({
get: v => v ?? localValue.value,
set: (v) => {
localValue.value = v;
return v;
},
});
const openSet = computed<Set<string>>(() => new Set(toArray(model.value)));
function nextOpenSet(cur: Set<string>, value: string): Set<string> {
const present = cur.has(value);
@@ -88,13 +85,12 @@ function nextOpenSet(cur: Set<string>, value: string): Set<string> {
return next;
}
function toEmitValue(set: Set<string>): string | string[] | undefined {
function toModelValue(set: Set<string>): string | string[] | undefined {
return type === 'single' ? set.values().next().value : [...set];
}
function commit(next: Set<string>): void {
openSet.value = next;
emit('update:modelValue', toEmitValue(next));
model.value = toModelValue(next);
}
function isOpen(value: string): boolean {
@@ -1,6 +1,12 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* The interactive header button that toggles its item's content open and
* closed. Renders as a `<button>` by default, wires up the correct ARIA
* (`aria-expanded`/`aria-controls`) and participates in arrow-key roving
* focus across all triggers.
*/
export interface AccordionTriggerProps extends PrimitiveProps {
}
</script>
+66
View File
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
AccordionContent,
AccordionItem,
AccordionRoot,
AccordionTrigger,
} from '@robonen/primitives';
const open = ref<string>('shipping');
const items = [
{
value: 'shipping',
question: 'How long does shipping take?',
answer: 'Orders ship within 12 business days and arrive in 35 days with standard delivery. Express options are offered at checkout.',
},
{
value: 'returns',
question: 'What is your return policy?',
answer: 'Returns are free within 30 days of delivery. Items must be unused and in their original packaging.',
},
{
value: 'support',
question: 'How can I reach support?',
answer: 'Our team is available 24/7 by email and weekdays 95 by live chat. Most tickets are answered within a few hours.',
},
];
</script>
<template>
<AccordionRoot
v-model="open"
type="single"
collapsible
class="w-full max-w-md divide-y divide-(--border) rounded-lg border border-(--border) bg-(--bg) text-(--fg)"
>
<AccordionItem
v-for="item in items"
:key="item.value"
:value="item.value"
>
<AccordionTrigger
class="group flex w-full items-center justify-between gap-4 px-4 py-3.5 text-left text-sm font-medium outline-none transition-colors hover:bg-(--bg-subtle) focus-visible:ring-2 focus-visible:ring-(--ring)"
>
<span>{{ item.question }}</span>
<svg
class="size-4 shrink-0 text-(--fg-subtle) transition-transform duration-200 group-data-[state=open]:rotate-180"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</AccordionTrigger>
<AccordionContent
class="px-4 pb-4 text-sm text-(--fg-muted)"
>
{{ item.answer }}
</AccordionContent>
</AccordionItem>
</AccordionRoot>
</template>
@@ -1,6 +1,11 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* The button that confirms the alert and closes the dialog. Use it for the
* action being warned about (e.g. "Delete"); wire your own handler to perform
* the work, the part only handles closing.
*/
export interface AlertDialogActionProps extends PrimitiveProps {}
</script>
@@ -1,6 +1,11 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* The button that dismisses the alert without acting and closes the dialog.
* Receives focus automatically when the alert opens, making it the safe default
* choice; always include one so the user has a non-destructive way out.
*/
export interface AlertDialogCancelProps extends PrimitiveProps {}
</script>
@@ -1,6 +1,12 @@
<script lang="ts">
import type { DialogContentEmits, DialogContentProps } from '../dialog';
/**
* The container for the alert's content, rendered into the portal with
* `role="alertdialog"`. Hosts the Title, Description, Cancel, and Action parts,
* moves focus to Cancel on open, and disables dismissal via outside clicks or
* loss of focus so the alert can only be resolved by an explicit choice.
*/
export interface AlertDialogContentProps extends Omit<DialogContentProps, 'role'> {}
export type AlertDialogContentEmits = DialogContentEmits;
</script>
@@ -1,6 +1,16 @@
<script lang="ts">
import type { DialogRootProps } from '../dialog';
/**
* A modal dialog that interrupts the user with important content and expects a
* deliberate response. Built on top of Dialog, but always modal and rendered
* with `role="alertdialog"` — focus moves to the Cancel button on open and
* outside clicks are ignored, so the user must explicitly confirm or cancel.
*
* Use it for destructive or irreversible actions (deleting data, discarding
* changes); for non-blocking content prefer Dialog instead. Manages open state
* and provides context to all parts. Bind `v-model:open` to control it.
*/
export interface AlertDialogRootProps extends Omit<DialogRootProps, 'modal'> {}
</script>
+86
View File
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
AlertDialogTrigger,
} from '@robonen/primitives';
const open = ref(false);
const deleted = ref(false);
function confirmDelete() {
deleted.value = true;
}
function restore() {
deleted.value = false;
}
</script>
<template>
<div class="flex flex-col items-start gap-3 text-(--fg)">
<p v-if="!deleted" class="text-sm text-(--fg-muted)">
Project <span class="font-medium text-(--fg)">"acme-web"</span> is live.
</p>
<p
v-else
class="text-sm text-red-600 dark:text-red-400"
>
Project deleted.
<button
type="button"
class="ml-1 underline underline-offset-2 hover:text-red-700 dark:hover:text-red-300"
@click="restore"
>
Undo
</button>
</p>
<AlertDialogRoot v-model:open="open">
<AlertDialogTrigger
:disabled="deleted"
class="inline-flex items-center rounded-md border border-red-300 bg-(--bg) px-3 py-1.5 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400 disabled:cursor-not-allowed disabled:opacity-50 dark:border-red-900 dark:text-red-400 dark:hover:bg-red-950/40"
>
Delete project
</AlertDialogTrigger>
<AlertDialogPortal>
<AlertDialogOverlay
class="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
/>
<AlertDialogContent
class="fixed left-1/2 top-1/2 z-50 w-[min(92vw,26rem)] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-(--border) bg-(--bg-elevated) p-5 shadow-xl"
>
<AlertDialogTitle class="text-base font-semibold text-(--fg)">
Delete this project?
</AlertDialogTitle>
<AlertDialogDescription class="mt-1.5 text-sm text-(--fg-muted)">
This permanently removes "acme-web" and all of its deployments.
This action cannot be undone.
</AlertDialogDescription>
<div class="mt-5 flex justify-end gap-2">
<AlertDialogCancel
class="inline-flex items-center rounded-md border border-(--border) bg-(--bg) px-3 py-1.5 text-sm font-medium text-(--fg) transition-colors hover:bg-(--bg-subtle) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--ring)"
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
class="inline-flex items-center rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-red-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
@click="confirmDelete"
>
Delete
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</div>
</template>
@@ -1,6 +1,12 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* Displays content within a fixed, responsive width-to-height ratio. The
* element grows to fill its container's width and derives its height from the
* `ratio`, so the box keeps its proportions at any size. Use it to reserve
* layout space for images, video, maps, or embeds and avoid content shift.
*/
export interface AspectRatioProps extends PrimitiveProps {
/**
* Desired width-to-height ratio (e.g. `16 / 9`, `1`, `4 / 3`).
@@ -14,7 +20,7 @@ export interface AspectRatioProps extends PrimitiveProps {
import { Primitive } from '../primitive';
import { useForwardExpose } from '@robonen/vue';
useForwardExpose();
const { forwardRef } = useForwardExpose();
const { ratio = 1, as = 'div' } = defineProps<AspectRatioProps>();
@@ -33,7 +39,7 @@ const INNER_STYLE = {
</script>
<template>
<div :style="wrapperStyle" data-aspect-ratio-wrapper>
<div :ref="forwardRef" :style="wrapperStyle" data-aspect-ratio-wrapper>
<Primitive :as="as" :style="INNER_STYLE" :data-aspect-ratio="true">
<slot />
</Primitive>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { AspectRatio } from '@robonen/primitives';
import { ref } from 'vue';
const ratios = [
{ label: '16 / 9', value: 16 / 9 },
{ label: '4 / 3', value: 4 / 3 },
{ label: '1 / 1', value: 1 },
] as const;
const ratio = ref(ratios[0].value);
</script>
<template>
<div class="flex flex-col gap-4 w-full max-w-md text-(--fg)">
<div class="flex items-center gap-1 p-1 rounded-lg bg-(--bg-inset) border border-(--border) w-fit">
<button
v-for="r in ratios"
:key="r.label"
type="button"
class="px-3 py-1 text-sm rounded-md transition-colors"
:class="ratio === r.value
? 'bg-(--accent) text-(--accent-fg)'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-subtle)'"
@click="ratio = r.value"
>
{{ r.label }}
</button>
</div>
<AspectRatio
:ratio="ratio"
class="overflow-hidden rounded-xl border border-(--border) bg-(--bg-subtle)"
>
<img
src="https://images.unsplash.com/photo-1535025183041-0991a977e25b?w=800&q=80"
alt="Mountain landscape at dusk"
class="h-full w-full object-cover"
>
</AspectRatio>
<p class="text-sm text-(--fg-muted)">
The frame keeps a fixed
<span class="font-medium text-(--fg)">{{ ratios.find((r) => r.value === ratio)?.label }}</span>
proportion as the container resizes, so the image never shifts surrounding layout.
</p>
</div>
</template>
@@ -1,6 +1,12 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* Content shown while the image is loading or when it fails to load — typically
* the user's initials or a generic icon. It renders only when the image is not
* yet `loaded`, and can be delayed to avoid a flash of fallback on fast
* connections.
*/
export interface AvatarFallbackProps extends PrimitiveProps {
/** Delay in ms before rendering the fallback (avoids flicker on fast networks). */
+8 -2
View File
@@ -2,11 +2,17 @@
import type { PrimitiveProps } from '../primitive';
import type { AvatarImageLoadingStatus } from './context';
/**
* The image to display. It loads the `src` out of band and only renders once
* the image has successfully loaded, reporting its loading status to the root
* so the fallback can take over while loading or on error.
*/
export interface AvatarImageProps extends PrimitiveProps {
/** Image source URL — loaded out of band before the image is shown. */
src?: string;
/** Alternative text describing the image. */
alt?: string;
/** Optional hook to reject loaded images by their dimensions/src. */
/** Called whenever the image's loading status changes (`idle`/`loading`/`loaded`/`error`). */
onLoadingStatusChange?: (status: AvatarImageLoadingStatus) => void;
}
</script>
+10
View File
@@ -1,6 +1,16 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* An image element representing a user, with a graceful text/icon fallback for
* when the image is loading or fails to load. Use it for profile pictures in
* avatars, comment threads, member lists, or anywhere a user identity is shown
* and you need a reliable placeholder.
*
* The root tracks the image's loading status and provides it via context so
* `AvatarImage` and `AvatarFallback` can coordinate which one is rendered. It
* exposes the current status on the `data-status` attribute for styling.
*/
export interface AvatarRootProps extends PrimitiveProps {}
</script>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { AvatarFallback, AvatarImage, AvatarRoot } from '@robonen/primitives';
const people = [
{
name: 'Ada Lovelace',
initials: 'AL',
src: 'https://i.pravatar.cc/96?img=47',
},
{
name: 'Alan Turing',
initials: 'AT',
src: 'https://example.com/this-image-does-not-exist.png',
},
{
name: 'Grace Hopper',
initials: 'GH',
src: '',
},
] as const;
</script>
<template>
<div class="flex items-center gap-4">
<div
v-for="person in people"
:key="person.name"
class="flex flex-col items-center gap-2"
>
<AvatarRoot
class="relative inline-flex h-14 w-14 select-none items-center justify-center overflow-hidden rounded-full border border-(--border) bg-(--bg-subtle) align-middle"
>
<AvatarImage
:src="person.src"
:alt="person.name"
class="h-full w-full rounded-[inherit] object-cover"
/>
<AvatarFallback
:delay-ms="200"
class="flex h-full w-full items-center justify-center bg-(--bg-inset) text-sm font-medium text-(--fg-muted)"
>
{{ person.initials }}
</AvatarFallback>
</AvatarRoot>
<span class="text-xs text-(--fg-subtle)">{{ person.name }}</span>
</div>
</div>
</template>
@@ -1,6 +1,12 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* A single `role="gridcell"` day container (`<td>`). Reflects the date's state
* (selected, disabled, unavailable, outside-view, today) as `data-*`
* attributes and `aria-*` for styling, and wraps the focusable
* `CalendarCellTrigger`.
*/
export interface CalendarCellProps extends PrimitiveProps {
/** The date this cell represents. */
date: Date;
@@ -1,6 +1,12 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* The focusable, clickable day button inside a `CalendarCell`. Selects its
* `day` on click/Enter/Space, drives roving focus and full arrow-key /
* Home-End / PageUp-Down keyboard navigation (paging the month when focus
* crosses the visible range), and exposes day state through its slot.
*/
export interface CalendarCellTriggerProps extends PrimitiveProps {
/** The day this trigger represents. */
day: Date;
@@ -1,6 +1,11 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* The `role="grid"` table for a single month. Provides grid context (the month
* it renders) to its head/body cells; render one per visible month when
* `numberOfMonths > 1`.
*/
export interface CalendarGridProps extends PrimitiveProps {
/** The month this grid represents. Defaults to the root placeholder's month. */
month?: Date;
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* The grid's `<tbody>` wrapper containing the week rows (`CalendarGridRow`) of
* day cells.
*/
export interface CalendarGridBodyProps extends PrimitiveProps {}
</script>
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* The grid's `<thead>` wrapper holding the row of weekday `CalendarHeadCell`
* labels.
*/
export interface CalendarGridHeadProps extends PrimitiveProps {}
</script>
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* A single table row (`<tr>`) representing one week of the month, or the
* weekday-label row inside the grid head.
*/
export interface CalendarGridRowProps extends PrimitiveProps {}
</script>
@@ -1,6 +1,11 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* A `scope="col"` weekday header cell (`<th>`). Renders the localized short
* label in its slot while exposing the full weekday name as the `aria-label`
* when a `day` is provided.
*/
export interface CalendarHeadCellProps extends PrimitiveProps {
/** The day this header cell represents — used for `aria-label`. */
day?: Date;
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* Layout container for the calendar's top bar. Holds the `CalendarPrev`,
* `CalendarHeading`, and `CalendarNext` controls above the month grid(s).
*/
export interface CalendarHeaderProps extends PrimitiveProps {}
</script>
@@ -1,6 +1,12 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* Displays the currently visible month and year (e.g. "June 2026"), or a range
* when multiple months are shown. Marked `aria-hidden` since the grid already
* carries the full accessible label; expose the value via its default slot to
* customize the rendering.
*/
export interface CalendarHeadingProps extends PrimitiveProps {}
</script>
@@ -1,6 +1,11 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* Button that pages the calendar forward (by one month, or by
* `numberOfMonths` when paged navigation is enabled). Auto-disables when the
* next page would fall after `maxValue` or the calendar is disabled.
*/
export interface CalendarNextProps extends PrimitiveProps {
/** Override the root's `nextPage` for just this button. */
nextPage?: (placeholder: Date) => Date;
@@ -1,6 +1,11 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* Button that pages the calendar backward (by one month, or by
* `numberOfMonths` when paged navigation is enabled). Auto-disables when the
* previous page would fall before `minValue` or the calendar is disabled.
*/
export interface CalendarPrevProps extends PrimitiveProps {
/** Override the root's `prevPage` for just this button. */
prevPage?: (placeholder: Date) => Date;
@@ -2,6 +2,17 @@
import type { PrimitiveProps } from '../primitive';
import type { CalendarMonth, WeekDayFormat } from './utils';
/**
* A fully accessible, headless date calendar for picking a single day. The
* root owns the selected value and the displayed month ("placeholder"), builds
* the localized month grid(s), and wires up roving keyboard navigation,
* min/max bounds, and disabled/unavailable predicates. Use it to build an
* inline date picker or as the body of a popover/`DatePicker`.
*
* Compose it with `CalendarHeader` (`CalendarPrev` / `CalendarHeading` /
* `CalendarNext`) and one `CalendarGrid` per month. Supports `v-model` for the
* selected date and `v-model:placeholder` for the visible month.
*/
export interface CalendarRootProps extends PrimitiveProps {
/** Uncontrolled default selected date. */
defaultValue?: Date;
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts">
import {
CalendarCell,
CalendarCellTrigger,
CalendarGrid,
CalendarGridBody,
CalendarGridHead,
CalendarGridRow,
CalendarHeadCell,
CalendarHeader,
CalendarHeading,
CalendarNext,
CalendarPrev,
CalendarRoot,
} from '@robonen/primitives';
import { ref } from 'vue';
const value = ref<Date>(new Date());
function formatSelected(date: Date | undefined) {
if (!date) return 'None';
return date.toLocaleDateString('en', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
}
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-3">
<CalendarRoot
v-slot="{ grid, weekDays }"
v-model="value"
class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 text-(--fg) shadow-sm"
>
<CalendarHeader class="mb-3 flex items-center justify-between gap-2">
<CalendarPrev
aria-label="Previous month"
class="inline-flex size-8 items-center justify-center rounded-lg border border-(--border) bg-(--bg) text-(--fg-muted) transition hover:bg-(--bg-inset) hover:text-(--fg) active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
>
</CalendarPrev>
<CalendarHeading class="text-sm font-semibold tracking-tight" />
<CalendarNext
aria-label="Next month"
class="inline-flex size-8 items-center justify-center rounded-lg border border-(--border) bg-(--bg) text-(--fg-muted) transition hover:bg-(--bg-inset) hover:text-(--fg) active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
>
</CalendarNext>
</CalendarHeader>
<CalendarGrid
v-for="month in grid"
:key="month.value.toString()"
:month="month.value"
class="w-full border-collapse select-none"
>
<CalendarGridHead>
<CalendarGridRow class="mb-1 flex">
<CalendarHeadCell
v-for="(weekday, i) in weekDays"
:key="weekday + i"
class="w-9 text-center text-xs font-medium text-(--fg-subtle)"
>
{{ weekday }}
</CalendarHeadCell>
</CalendarGridRow>
</CalendarGridHead>
<CalendarGridBody>
<CalendarGridRow
v-for="(week, w) in month.weeks"
:key="w"
class="flex w-full"
>
<CalendarCell
v-for="day in week"
:key="day.toString()"
:date="day"
class="p-0.5"
>
<CalendarCellTrigger
v-slot="{ dayValue, selected, today }"
:day="day"
:month="month.value"
class="flex size-8 items-center justify-center rounded-lg text-sm tabular-nums transition outline-none cursor-pointer
focus-visible:ring-2 focus-visible:ring-(--ring)
hover:bg-(--bg-inset)
data-[selected]:bg-(--accent) data-[selected]:font-semibold data-[selected]:text-(--accent-fg) data-[selected]:hover:bg-(--accent-hover)
data-[outside-view]:text-(--fg-subtle) data-[outside-view]:opacity-50
data-[unavailable]:cursor-not-allowed data-[unavailable]:text-red-500 data-[unavailable]:line-through data-[unavailable]:hover:bg-transparent
data-[disabled]:cursor-not-allowed data-[disabled]:opacity-30"
>
<span
:class="[
today && !selected ? 'relative after:absolute after:bottom-1 after:left-1/2 after:size-1 after:-translate-x-1/2 after:rounded-full after:bg-(--accent)' : '',
]"
>
{{ dayValue }}
</span>
</CalendarCellTrigger>
</CalendarCell>
</CalendarGridRow>
</CalendarGridBody>
</CalendarGrid>
</CalendarRoot>
<p class="text-xs text-(--fg-muted)">
Selected:
<span class="font-medium text-(--fg)">{{ formatSelected(value) }}</span>
</p>
</div>
</template>
@@ -1,5 +1,10 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* Renders its content only when the parent `CheckboxRoot` is checked or
* indeterminate, mirroring that state via `data-state`. Place the check/dash
* icon inside it; use `forceMount` to keep it mounted for CSS exit animations.
*/
export interface CheckboxIndicatorProps extends PrimitiveProps {
/** Keep mounted even when unchecked (for CSS exit animations). */
forceMount?: boolean;
+39 -19
View File
@@ -2,6 +2,14 @@
import type { PrimitiveProps } from '../primitive';
import type { CheckedState } from './context';
/**
* A toggleable control with checked, unchecked, and `'indeterminate'` states,
* built on a native `<button role="checkbox">`. The interactive root: it owns
* the checked state (controlled via `v-model:checked` or uncontrolled via
* `defaultChecked`), handles toggling, exposes a hidden form input when `name`
* is set, and provides context to `CheckboxIndicator`. Use it whenever you need
* a styled checkbox that integrates with forms or supports a mixed/partial state.
*/
export interface CheckboxRootProps extends PrimitiveProps {
/** Uncontrolled initial checked state. */
defaultChecked?: CheckedState;
@@ -22,7 +30,7 @@ export interface CheckboxRootEmits {
<script setup lang="ts">
import { Primitive } from '../primitive';
import { ref, toRef, watch } from 'vue';
import { computed, ref, toRef } from 'vue';
import { provideCheckboxContext } from './context';
import { useForwardExpose } from '@robonen/vue';
@@ -31,40 +39,51 @@ const { disabled = false, required = false, value = 'on', defaultChecked, name,
const { forwardRef } = useForwardExpose();
const emit = defineEmits<CheckboxRootEmits>();
const model = defineModel<CheckedState | undefined>('checked', { default: undefined });
const localChecked = ref<CheckedState>(model.value ?? defaultChecked ?? false);
const localChecked = ref<CheckedState>(defaultChecked ?? false);
watch(model, (v) => {
if (v === undefined) return;
if (v !== localChecked.value) localChecked.value = v;
// `defineModel` handles both controlled (parent `v-model:checked`) and
// uncontrolled modes; `localChecked` backs the uncontrolled state seeded from
// `defaultChecked`. `checkedChange` is a separate public emit, so it stays.
const checked = defineModel<CheckedState | undefined>('checked', {
default: undefined,
get: v => v ?? localChecked.value,
set: (v) => {
localChecked.value = v as CheckedState;
return v;
},
});
function setChecked(v: CheckedState): void {
localChecked.value = v;
model.value = v;
checked.value = v;
emit('checkedChange', v);
}
function toggle(): void {
if (disabled) return;
setChecked(localChecked.value !== true);
setChecked(checked.value !== true);
}
function onKeyDown(event: KeyboardEvent): void {
// Prevent form submit on Enter when inside a form.
if (event.key === 'Enter') event.preventDefault();
// <button> handles Space natively; synthesize toggle only for non-button hosts.
if (as !== 'button' && event.key === ' ') {
event.preventDefault();
toggle();
}
}
// Read through the model so the context reflects both controlled (parent
// `v-model:checked`) and uncontrolled state; coalesce the model's `undefined`
// default to `false`. `toRef(() => disabled)` gives a reactive identity
// passthrough without `ReactiveEffect`/cache.
const checkedState = computed<CheckedState>(() => checked.value ?? false);
provideCheckboxContext({
// `localChecked` is already a `Ref<CheckedState>`; forward directly without
// wrapping in a computed. `toRef(() => disabled)` gives a reactive identity
// passthrough without `ReactiveEffect`/cache.
checked: localChecked,
checked: checkedState,
disabled: toRef(() => disabled),
});
// Inlined in template — no need for a cached computed for a single call site.
</script>
<template>
@@ -72,17 +91,18 @@ provideCheckboxContext({
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
:tabindex="as === 'button' ? undefined : (disabled ? -1 : 0)"
role="checkbox"
:aria-checked="localChecked === 'indeterminate' ? 'mixed' : localChecked"
:aria-checked="checkedState === 'indeterminate' ? 'mixed' : checkedState"
:aria-required="required || undefined"
:aria-disabled="disabled || undefined"
:data-state="localChecked === 'indeterminate' ? 'indeterminate' : (localChecked ? 'checked' : 'unchecked')"
:data-state="checkedState === 'indeterminate' ? 'indeterminate' : (checkedState ? 'checked' : 'unchecked')"
:data-disabled="disabled ? '' : undefined"
:disabled="disabled || undefined"
@click="toggle"
@keydown="onKeyDown"
>
<slot :checked="localChecked" />
<slot :checked="checkedState" />
<input
v-if="name"
type="checkbox"
@@ -90,7 +110,7 @@ provideCheckboxContext({
aria-hidden="true"
:name="name"
:value="value"
:checked="localChecked === true"
:checked="checkedState === true"
:required="required"
:disabled="disabled"
style="position: absolute; pointer-events: none; opacity: 0; margin: 0; transform: translateX(-100%);"
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { CheckedState } from '@robonen/primitives';
import { CheckboxIndicator, CheckboxRoot } from '@robonen/primitives';
import { computed, ref } from 'vue';
const ingredients = [
{ id: 'cheese', label: 'Extra cheese' },
{ id: 'mushrooms', label: 'Mushrooms' },
{ id: 'olives', label: 'Olives' },
];
const selected = ref<Record<string, boolean>>({
cheese: true,
mushrooms: false,
olives: false,
});
const checkedCount = computed(() => Object.values(selected.value).filter(Boolean).length);
// Parent reflects the children: checked when all, unchecked when none, else indeterminate.
const allChecked = computed<CheckedState>(() => {
if (checkedCount.value === 0) return false;
if (checkedCount.value === ingredients.length) return true;
return 'indeterminate';
});
function toggleAll(next: CheckedState) {
const value = next === true;
for (const item of ingredients) selected.value[item.id] = value;
}
const acceptedTerms = ref(false);
</script>
<template>
<div class="flex flex-col gap-6 p-6 max-w-sm bg-(--bg) text-(--fg) border border-(--border) rounded-xl">
<fieldset class="flex flex-col gap-3 m-0 p-0 border-0">
<legend class="text-sm font-semibold text-(--fg)">
Toppings
</legend>
<label class="flex items-center gap-3 cursor-pointer select-none">
<CheckboxRoot
:checked="allChecked"
class="grid place-items-center w-5 h-5 rounded-md border border-(--border) bg-(--bg-inset) outline-none transition-colors data-[state=checked]:bg-(--accent) data-[state=indeterminate]:bg-(--accent) data-[state=checked]:border-(--accent) data-[state=indeterminate]:border-(--accent) focus-visible:ring-2 focus-visible:ring-(--ring)"
@checked-change="toggleAll"
>
<CheckboxIndicator v-slot="{ checked }" class="text-(--accent-fg)">
<svg v-if="checked === 'indeterminate'" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M2.5 6h7" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<svg v-else width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M2.5 6.5 5 9l4.5-5.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</CheckboxIndicator>
</CheckboxRoot>
<span class="text-sm font-medium">Select all</span>
<span class="ml-auto text-xs text-(--fg-subtle)">{{ checkedCount }}/{{ ingredients.length }}</span>
</label>
<div class="flex flex-col gap-2 pl-2 border-l border-(--border)">
<label v-for="item in ingredients" :key="item.id" class="flex items-center gap-3 cursor-pointer select-none">
<CheckboxRoot
v-model:checked="selected[item.id]"
class="grid place-items-center w-5 h-5 rounded-md border border-(--border) bg-(--bg-inset) outline-none transition-colors data-[state=checked]:bg-(--accent) data-[state=checked]:border-(--accent) focus-visible:ring-2 focus-visible:ring-(--ring)"
>
<CheckboxIndicator class="text-(--accent-fg)">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M2.5 6.5 5 9l4.5-5.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</CheckboxIndicator>
</CheckboxRoot>
<span class="text-sm text-(--fg)">{{ item.label }}</span>
</label>
</div>
</fieldset>
<label class="flex items-start gap-3 cursor-pointer select-none">
<CheckboxRoot
v-model:checked="acceptedTerms"
required
class="grid place-items-center w-5 h-5 mt-0.5 rounded-md border border-(--border) bg-(--bg-inset) outline-none transition-colors data-[state=checked]:bg-emerald-500 data-[state=checked]:border-emerald-500 dark:data-[state=checked]:bg-emerald-400 dark:data-[state=checked]:border-emerald-400 focus-visible:ring-2 focus-visible:ring-(--ring)"
>
<CheckboxIndicator class="text-white dark:text-(--bg)">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M2.5 6.5 5 9l4.5-5.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</CheckboxIndicator>
</CheckboxRoot>
<span class="text-sm text-(--fg-muted)">I accept the terms and conditions</span>
</label>
<p
class="text-xs"
:class="acceptedTerms ? 'text-emerald-600 dark:text-emerald-400' : 'text-(--fg-subtle)'"
>
{{ acceptedTerms ? 'Ready to submit' : 'Please accept the terms to continue' }}
</p>
</div>
</template>
@@ -1,6 +1,11 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* The panel revealed when the collapsible is open. Mounts and unmounts with
* the open state (via `Presence`), is referenced by the trigger's
* `aria-controls`, and is hidden from layout and assistive tech while closed.
*/
export interface CollapsibleContentProps extends PrimitiveProps {
/** Render the content even when closed (useful for animation control). */
@@ -1,6 +1,14 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* An interactive component that expands and collapses a panel of content.
*
* `CollapsibleRoot` owns the open/closed state (controlled via `v-model:open`
* or uncontrolled via `defaultOpen`), provides it to the `Trigger` and
* `Content` parts, and reflects it as `data-state`. Use it for show/hide
* disclosures such as "read more" sections, FAQ entries, or settings panels.
*/
export interface CollapsibleRootProps extends PrimitiveProps {
defaultOpen?: boolean;
@@ -1,6 +1,11 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* The button that toggles the collapsible open and closed. Wires up
* `aria-expanded`, `aria-controls`, and the disabled state from the root, and
* renders as a `<button>` by default.
*/
export interface CollapsibleTriggerProps extends PrimitiveProps {}
</script>
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
CollapsibleContent,
CollapsibleRoot,
CollapsibleTrigger,
} from '@robonen/primitives';
const open = ref(false);
const commits = [
{ id: 'a1c3f9', msg: 'Reflect open state via data-state' },
{ id: 'b7e2d4', msg: 'Wire aria-controls to content id' },
{ id: 'c0f8a1', msg: 'Unmount content with Presence when closed' },
];
</script>
<template>
<CollapsibleRoot
v-model:open="open"
class="w-full max-w-sm rounded-xl border border-(--border) bg-(--bg-elevated) p-3 text-(--fg)"
>
<div class="flex items-center justify-between gap-3 px-1">
<span class="text-sm font-medium">
<span class="font-mono text-(--fg-muted)">@robonen</span> pushed 3 commits
</span>
<CollapsibleTrigger
class="inline-flex size-7 items-center justify-center rounded-md border border-(--border) bg-(--bg) text-(--fg-muted) transition hover:bg-(--bg-inset) hover:text-(--fg) active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
:aria-label="open ? 'Collapse commits' : 'Expand commits'"
>
<svg
class="size-4 transition-transform duration-200"
:class="open ? 'rotate-180' : ''"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
</CollapsibleTrigger>
</div>
<div class="mt-2 rounded-lg border border-(--border) bg-(--bg) px-3 py-2 font-mono text-xs text-(--fg-muted)">
<span class="text-emerald-600 dark:text-emerald-400">{{ commits[0].id }}</span>
{{ commits[0].msg }}
</div>
<CollapsibleContent class="mt-1.5 space-y-1.5">
<div
v-for="commit in commits.slice(1)"
:key="commit.id"
class="rounded-lg border border-(--border) bg-(--bg) px-3 py-2 font-mono text-xs text-(--fg-muted)"
>
<span class="text-emerald-600 dark:text-emerald-400">{{ commit.id }}</span>
{{ commit.msg }}
</div>
</CollapsibleContent>
</CollapsibleRoot>
</template>
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PopperAnchorProps } from '../popper';
/**
* The element the popup is positioned against, typically wrapping the Input and Trigger.
* Acts as the Popper anchor and the boundary used for the blur-to-close heuristic.
*/
export interface ComboboxAnchorProps extends PopperAnchorProps {}
</script>
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PopperArrowProps } from '../popper';
/**
* An optional arrow that visually points from the popup back to the anchor. Renders only
* while the combobox is open. Place inside ComboboxContent.
*/
export type ComboboxArrowProps = PopperArrowProps;
</script>
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* A button that clears the current search term and refocuses the input. Typically shown
* as an "x" inside the field while the user is typing.
*/
export interface ComboboxCancelProps extends PrimitiveProps {}
</script>
@@ -1,6 +1,10 @@
<script lang="ts">
import type { ComboboxContentImplEmits, ComboboxContentImplProps } from './ComboboxContentImpl.vue';
/**
* The popup listbox that holds the options. Mounts only while open (via Presence) and
* positions itself relative to the anchor. Place the Viewport, Items, and Empty inside it.
*/
export type ComboboxContentProps = ComboboxContentImplProps;
export type ComboboxContentEmits = ComboboxContentImplEmits;
</script>
@@ -4,6 +4,10 @@ import type { FocusScopeEmits } from '../focus-scope';
import type { PopperContentProps } from '../popper';
import type { PrimitiveProps } from '../primitive';
/**
* Internal implementation of the content popup: wires up focus scoping, dismiss-on-outside,
* Popper positioning, and the screen-reader result announcer. Use ComboboxContent instead.
*/
export interface ComboboxContentImplProps extends PrimitiveProps, /* @vue-ignore */ Partial<PopperContentProps> {
/** Position strategy. @default 'popper' */
position?: 'inline' | 'popper';
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* Fallback content shown when the current search term matches no items. Renders only when
* the filtered count is zero, unless `always` is set.
*/
export interface ComboboxEmptyProps extends PrimitiveProps {
/** Render even when items exist but none are filtered out. */
always?: boolean;
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* Groups related items under a shared ComboboxLabel. Hides itself automatically when none
* of its items survive the current filter.
*/
export interface ComboboxGroupProps extends PrimitiveProps {}
</script>
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* The text field users type into to filter options. Owns the search term, ARIA combobox
* semantics, and keyboard navigation (arrows, Home/End, Enter to select, Escape to close).
*/
export interface ComboboxInputProps extends PrimitiveProps {
/** Disable the input. */
disabled?: boolean;
@@ -2,6 +2,10 @@
import type { PrimitiveProps } from '../primitive';
import type { AcceptableValue } from './utils';
/**
* A single selectable option in the list. Registers itself for filtering and keyboard
* navigation, toggles selection on click, and highlights on pointer move.
*/
export interface ComboboxItemProps<T extends AcceptableValue = AcceptableValue> extends PrimitiveProps {
/** Item value. Selected/registered identity. */
value: T;
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* Marks the selected state of its parent ComboboxItem, e.g. a checkmark. Renders only when
* that item is selected.
*/
export interface ComboboxItemIndicatorProps extends PrimitiveProps {}
</script>
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* An accessible label for a ComboboxGroup. Its id is referenced by the group's
* `aria-labelledby`, so place it as a direct child of ComboboxGroup.
*/
export interface ComboboxLabelProps extends PrimitiveProps {}
</script>
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PortalProps } from '../teleport';
/**
* Teleports the ComboboxContent into another part of the DOM (defaults to `body`) to escape
* overflow/stacking-context clipping. Wrap ComboboxContent with it.
*/
export interface ComboboxPortalProps extends PortalProps {}
</script>
+7 -3
View File
@@ -2,6 +2,13 @@
import type { Direction } from '../config-provider';
import type { AcceptableValue, ComboboxFilterFunction, ComboboxFilterItem } from './utils';
/**
* An autocomplete / typeahead input that filters a list of options as the user types.
* Combine a text input with a popup listbox, supporting single or multiple selection,
* custom filtering, and full keyboard navigation. Reach for it when users must pick from
* a large or searchable set of options; for a small fixed list a plain Select is simpler.
* Wraps everything in a Popper and provides shared state to every other Combobox part.
*/
export interface ComboboxRootProps<T extends AcceptableValue = AcceptableValue> {
/** Controlled selected value. Use `v-model`. */
modelValue?: T | T[];
@@ -69,8 +76,6 @@ const {
by,
} = defineProps<ComboboxRootProps<T>>();
const emit = defineEmits<ComboboxRootEmits<T>>();
const config = useConfig();
const direction = computed(() => dir ?? config.dir.value);
@@ -203,7 +208,6 @@ function isSelected(v: T): boolean {
function commitValue(next: T | T[] | undefined) {
value.value = next;
emit('update:modelValue', next);
}
function onValueChange(v: T) {
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* A purely visual divider between items or groups inside the popup. Decorative and hidden
* from assistive technology.
*/
export interface ComboboxSeparatorProps extends PrimitiveProps {}
</script>
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* A button, usually a chevron next to the input, that toggles the popup open and closed.
* Optional: typing in the Input also opens the list.
*/
export interface ComboboxTriggerProps extends PrimitiveProps {
/** Disable the trigger independently from the root. */
disabled?: boolean;
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* The scrollable region inside ComboboxContent that holds the items. Provides the overflow
* container that keeps the highlighted item scrolled into view.
*/
export interface ComboboxViewportProps extends PrimitiveProps {}
</script>
+119
View File
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
ComboboxAnchor,
ComboboxCancel,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxItemIndicator,
ComboboxLabel,
ComboboxPortal,
ComboboxRoot,
ComboboxTrigger,
ComboboxViewport,
} from '@robonen/primitives';
interface Framework {
value: string;
label: string;
group: 'JavaScript' | 'Native';
}
const frameworks: Framework[] = [
{ value: 'vue', label: 'Vue', group: 'JavaScript' },
{ value: 'react', label: 'React', group: 'JavaScript' },
{ value: 'svelte', label: 'Svelte', group: 'JavaScript' },
{ value: 'solid', label: 'Solid', group: 'JavaScript' },
{ value: 'angular', label: 'Angular', group: 'JavaScript' },
{ value: 'swiftui', label: 'SwiftUI', group: 'Native' },
{ value: 'compose', label: 'Jetpack Compose', group: 'Native' },
{ value: 'flutter', label: 'Flutter', group: 'Native' },
];
const selected = ref<string>();
function labelFor(value: string | undefined) {
return frameworks.find(f => f.value === value)?.label ?? '';
}
const groups = ['JavaScript', 'Native'] as const;
</script>
<template>
<div class="flex w-full max-w-xs flex-col gap-3">
<ComboboxRoot
v-model="selected"
:display-value="labelFor"
class="relative"
>
<ComboboxAnchor
class="flex items-center gap-1 rounded-lg border border-(--border) bg-(--bg-inset) px-2 py-1.5 focus-within:border-(--accent) focus-within:ring-2 focus-within:ring-(--ring)"
>
<ComboboxInput
placeholder="Search a framework..."
open-on-click
class="min-w-0 flex-1 bg-transparent px-1 text-sm text-(--fg) outline-none placeholder:text-(--fg-subtle)"
/>
<ComboboxCancel
class="grid size-5 place-items-center rounded text-(--fg-subtle) hover:bg-(--bg-subtle) hover:text-(--fg)"
>
<span aria-hidden="true" class="text-xs"></span>
</ComboboxCancel>
<ComboboxTrigger
class="grid size-5 place-items-center rounded text-(--fg-muted) hover:bg-(--bg-subtle) hover:text-(--fg) data-[state=open]:rotate-180"
>
<span aria-hidden="true" class="text-xs"></span>
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxPortal>
<ComboboxContent
:side-offset="6"
class="z-50 w-(--popper-anchor-width) overflow-hidden rounded-lg border border-(--border) bg-(--bg-elevated) shadow-lg"
>
<ComboboxViewport class="max-h-60 p-1">
<ComboboxEmpty class="px-3 py-6 text-center text-sm text-(--fg-subtle)">
No frameworks found.
</ComboboxEmpty>
<ComboboxGroup
v-for="group in groups"
:key="group"
class="mb-1 last:mb-0"
>
<ComboboxLabel
class="px-2 py-1 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)"
>
{{ group }}
</ComboboxLabel>
<ComboboxItem
v-for="framework in frameworks.filter(f => f.group === group)"
:key="framework.value"
:value="framework.value"
:text-value="framework.label"
class="flex cursor-pointer items-center justify-between rounded-md px-2 py-1.5 text-sm text-(--fg) outline-none data-[highlighted]:bg-(--accent) data-[highlighted]:text-(--accent-fg) data-[disabled]:opacity-50"
>
<span>{{ framework.label }}</span>
<ComboboxItemIndicator>
<span aria-hidden="true"></span>
</ComboboxItemIndicator>
</ComboboxItem>
</ComboboxGroup>
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
<p class="text-sm text-(--fg-muted)">
Selected:
<span class="font-medium text-(--fg)">{{ selected ? labelFor(selected) : 'none' }}</span>
</p>
</div>
</template>
@@ -1,6 +1,11 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* Empty-state message shown when the search yields no matching items. By default
* it appears only while a search term is active; set `always` to also show it for
* an empty list with no query.
*/
export interface CommandEmptyProps extends PrimitiveProps {
/** Render even while there is no active search term. */
always?: boolean;
@@ -1,6 +1,11 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* Labelled section that visually clusters related items under an optional
* heading. Hides itself automatically when every item it contains is filtered
* out (unless `forceMount`), so empty categories disappear during search.
*/
export interface CommandGroupProps extends PrimitiveProps {
/** Group heading text (rendered when the default slot doesn't override it). */
heading?: string;
@@ -1,6 +1,11 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* Search box that drives the command palette: typing updates the root search
* term (and re-filters items), while Arrow/Home/End/Enter move the highlight and
* commit the selected item. Renders a combobox `<input>` wired up for assistive tech.
*/
export interface CommandInputProps extends PrimitiveProps {
/** Controlled value; falls back to root `searchTerm`. */
modelValue?: string;
@@ -1,6 +1,11 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* A selectable option in the list. Registers itself with the root (so it can be
* filtered, highlighted, and selected), reflects highlight/selection/disabled
* state via data attributes, and emits `select` when chosen by click or Enter.
*/
export interface CommandItemProps extends PrimitiveProps {
/** Item value — used by filter, selection, and `data-value`. */
value: string;
@@ -1,6 +1,11 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* Scrollable listbox container that holds the items, groups, and empty/loading
* states. Tracks its content height in the `--primitives-command-list-height`
* CSS variable so you can animate the palette as results filter in and out.
*/
export interface CommandListProps extends PrimitiveProps {}
</script>
@@ -1,6 +1,11 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* Progress indicator for asynchronous results — render it inside the list while
* fetching items so screen readers announce the loading state. Exposes an
* optional `progress` value as an accessible progressbar.
*/
export interface CommandLoadingProps extends PrimitiveProps {
/** Accessible label describing the loading state. */
label?: string;
+9 -4
View File
@@ -2,6 +2,15 @@
import type { PrimitiveProps } from '../primitive';
import type { CommandFilterFunction } from './utils';
/**
* Root of a command palette / fuzzy-finder menu (cmdk-style): owns the search
* term, the registry of items and groups, scoring/filtering, and keyboard-driven
* highlight + selection. Compose it with `CommandInput`, `CommandList`,
* `CommandGroup`, `CommandItem`, `CommandEmpty`, `CommandLoading`, and
* `CommandSeparator`. Reach for it whenever you need a searchable, keyboard-first
* list of actions or options — a Spotlight-style launcher, an autocomplete menu,
* or a quick-switcher.
*/
export interface CommandRootProps extends PrimitiveProps {
/** Controlled selected value. Use `v-model`. */
modelValue?: string;
@@ -50,8 +59,6 @@ const {
label,
} = defineProps<CommandRootProps>();
const emit = defineEmits<CommandRootEmits>();
const { forwardRef } = useForwardExpose();
const localValue = ref<string | undefined>(defaultValue);
@@ -179,12 +186,10 @@ function getItemId(val: string): string {
function setModelValue(v: string | undefined) {
value.value = v;
emit('update:modelValue', v);
}
function setSearchTerm(v: string) {
search.value = v;
emit('update:searchTerm', v);
}
function setSelectedValue(v: string | undefined) {
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* Visual divider between groups or items. Hidden automatically while a search
* term is active (since filtering collapses the list) unless `alwaysRender` is set.
*/
export interface CommandSeparatorProps extends PrimitiveProps {
/** Render the separator even while the search term is active. */
alwaysRender?: boolean;
+134
View File
@@ -0,0 +1,134 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandRoot,
CommandSeparator,
} from '@robonen/primitives';
interface Action {
value: string;
label: string;
icon: string;
keywords?: string[];
shortcut?: string;
}
const navigation: Action[] = [
{ value: 'home', label: 'Go to Dashboard', icon: 'i-carbon-dashboard', keywords: ['overview', 'start'] },
{ value: 'projects', label: 'Open Projects', icon: 'i-carbon-folder', keywords: ['repos', 'work'] },
{ value: 'settings', label: 'Open Settings', icon: 'i-carbon-settings', keywords: ['preferences', 'config'], shortcut: ',' },
];
const actions: Action[] = [
{ value: 'new-file', label: 'Create new file', icon: 'i-carbon-document-add', keywords: ['add'], shortcut: 'N' },
{ value: 'invite', label: 'Invite teammate', icon: 'i-carbon-user-follow', keywords: ['member', 'share'] },
{ value: 'theme', label: 'Toggle dark mode', icon: 'i-carbon-moon', keywords: ['appearance', 'light'] },
{ value: 'archive', label: 'Archive workspace', icon: 'i-carbon-archive', keywords: ['delete'] },
];
const selected = ref<string>();
const lastRun = ref<string>();
function run(value: string) {
lastRun.value = value;
}
</script>
<template>
<div class="flex flex-col items-center gap-4 p-6 bg-(--bg-inset) text-(--fg)">
<CommandRoot
v-model="selected"
label="Command palette"
loop
class="w-full max-w-100 overflow-hidden rounded-xl border border-(--border) bg-(--bg-elevated) shadow-lg"
>
<template #default="{ filteredCount }">
<!-- Search -->
<div class="flex items-center gap-2 border-b border-(--border) px-3">
<span class="i-carbon-search shrink-0 text-(--fg-subtle)" aria-hidden="true" />
<CommandInput
auto-focus
placeholder="Type a command or search…"
class="w-full bg-transparent py-3 text-sm text-(--fg) outline-none placeholder:text-(--fg-subtle)"
/>
<span class="shrink-0 text-xs tabular-nums text-(--fg-subtle)">{{ filteredCount }}</span>
</div>
<!-- Results -->
<CommandList class="max-h-72 overflow-y-auto p-2">
<CommandEmpty class="px-3 py-8 text-center text-sm text-(--fg-muted)">
No results found.
</CommandEmpty>
<CommandGroup
heading="Navigation"
class="[&_[data-primitives-command-group-heading]]:px-2 [&_[data-primitives-command-group-heading]]:pb-1 [&_[data-primitives-command-group-heading]]:pt-2 [&_[data-primitives-command-group-heading]]:text-xs [&_[data-primitives-command-group-heading]]:font-medium [&_[data-primitives-command-group-heading]]:text-(--fg-subtle)"
>
<template #default>
<CommandItem
v-for="item in navigation"
:key="item.value"
:value="item.value"
:keywords="item.keywords"
class="group flex cursor-pointer items-center gap-2 rounded-md px-2 py-2 text-sm data-[state=selected]:bg-(--accent) data-[state=selected]:text-(--accent-fg)"
@select="run"
>
<template #default="{ selected: isSelected }">
<span :class="item.icon" class="shrink-0 text-(--fg-muted) group-data-[state=selected]:text-(--accent-fg)" aria-hidden="true" />
<span class="flex-1">{{ item.label }}</span>
<kbd
v-if="item.shortcut"
class="rounded border border-(--border) bg-(--bg-subtle) px-1.5 text-xs text-(--fg-muted) group-data-[state=selected]:border-transparent"
>{{ item.shortcut }}</kbd>
<span v-if="isSelected" class="i-carbon-checkmark text-emerald-500 dark:text-emerald-400" aria-hidden="true" />
</template>
</CommandItem>
</template>
</CommandGroup>
<CommandSeparator class="my-2 h-px bg-(--border)" />
<CommandGroup
heading="Actions"
class="[&_[data-primitives-command-group-heading]]:px-2 [&_[data-primitives-command-group-heading]]:pb-1 [&_[data-primitives-command-group-heading]]:pt-2 [&_[data-primitives-command-group-heading]]:text-xs [&_[data-primitives-command-group-heading]]:font-medium [&_[data-primitives-command-group-heading]]:text-(--fg-subtle)"
>
<template #default>
<CommandItem
v-for="item in actions"
:key="item.value"
:value="item.value"
:keywords="item.keywords"
:disabled="item.value === 'archive'"
class="group flex cursor-pointer items-center gap-2 rounded-md px-2 py-2 text-sm data-[state=selected]:bg-(--accent) data-[state=selected]:text-(--accent-fg) data-[disabled]:cursor-not-allowed data-[disabled]:opacity-40"
@select="run"
>
<span :class="item.icon" class="shrink-0 text-(--fg-muted) group-data-[state=selected]:text-(--accent-fg)" aria-hidden="true" />
<span class="flex-1">{{ item.label }}</span>
<span v-if="item.value === 'archive'" class="text-xs text-red-500 dark:text-red-400">disabled</span>
<kbd
v-else-if="item.shortcut"
class="rounded border border-(--border) bg-(--bg-subtle) px-1.5 text-xs text-(--fg-muted) group-data-[state=selected]:border-transparent"
>{{ item.shortcut }}</kbd>
</CommandItem>
</template>
</CommandGroup>
</CommandList>
</template>
</CommandRoot>
<p class="text-sm text-(--fg-muted)">
<template v-if="lastRun">
Ran: <code class="rounded bg-(--bg-subtle) px-1.5 py-0.5 font-medium text-(--fg)">{{ lastRun }}</code>
</template>
<template v-else>
Use to navigate, Enter to run.
</template>
</p>
</div>
</template>
@@ -1,6 +1,10 @@
<script lang="ts">
import type { MenuArrowProps } from '../menu';
/**
* An optional arrow that visually points from the content back toward its
* anchor. Render it inside the content.
*/
export interface ContextMenuArrowProps extends MenuArrowProps {}
</script>
@@ -1,6 +1,11 @@
<script lang="ts">
import type { MenuCheckboxItemEmits, MenuCheckboxItemProps } from '../menu';
/**
* An item that toggles an on/off (or indeterminate) state, exposing
* `aria-checked` for assistive tech. Bind `v-model:checked` and pair it with a
* `ContextMenuItemIndicator` to render the checkmark.
*/
export interface ContextMenuCheckboxItemProps extends MenuCheckboxItemProps {}
export type ContextMenuCheckboxItemEmits = MenuCheckboxItemEmits;
</script>
@@ -1,6 +1,11 @@
<script lang="ts">
import type { MenuContentEmits, MenuContentProps } from '../menu';
/**
* The floating surface that holds the menu items, positioned at the pointer
* where the menu was invoked. Handles focus management, typeahead, and
* dismissal on outside click or Escape; render it inside a portal.
*/
export interface ContextMenuContentProps extends MenuContentProps {}
export type ContextMenuContentEmits = MenuContentEmits;
</script>
@@ -1,6 +1,10 @@
<script lang="ts">
import type { MenuGroupProps } from '../menu';
/**
* Groups a set of related items under one accessible `role="group"`, optionally
* labelled by a `ContextMenuLabel`. Use it to organize the menu into sections.
*/
export interface ContextMenuGroupProps extends MenuGroupProps {}
</script>
@@ -1,6 +1,10 @@
<script lang="ts">
import type { MenuItemEmits, MenuItemProps } from '../menu';
/**
* A single actionable command in the menu. Emits `select` on click or Enter
* and closes the menu by default; can be `disabled` for unavailable actions.
*/
export interface ContextMenuItemProps extends MenuItemProps {}
export type ContextMenuItemEmits = MenuItemEmits;
</script>
@@ -1,6 +1,11 @@
<script lang="ts">
import type { MenuItemIndicatorProps } from '../menu';
/**
* Renders its content only when the parent checkbox or radio item is checked.
* Place it inside a `ContextMenuCheckboxItem` or `ContextMenuRadioItem` to show
* the check or dot.
*/
export interface ContextMenuItemIndicatorProps extends MenuItemIndicatorProps {}
</script>
@@ -1,6 +1,10 @@
<script lang="ts">
import type { MenuLabelProps } from '../menu';
/**
* A non-interactive caption for a group of items. Skipped by keyboard
* navigation; use it to title a section within the menu.
*/
export interface ContextMenuLabelProps extends MenuLabelProps {}
</script>
@@ -1,6 +1,11 @@
<script lang="ts">
import type { MenuPortalProps } from '../menu';
/**
* Teleports the menu content into `document.body` (or a custom target) so it
* escapes parent overflow and stacking contexts. Place it between the trigger
* and the content.
*/
export interface ContextMenuPortalProps extends MenuPortalProps {}
</script>
@@ -1,6 +1,10 @@
<script lang="ts">
import type { MenuRadioGroupEmits, MenuRadioGroupProps } from '../menu';
/**
* A group of mutually exclusive `ContextMenuRadioItem`s sharing a single
* selected value. Bind `v-model` to track the active choice.
*/
export interface ContextMenuRadioGroupProps extends MenuRadioGroupProps {}
export type ContextMenuRadioGroupEmits = MenuRadioGroupEmits;
</script>
@@ -1,6 +1,11 @@
<script lang="ts">
import type { MenuRadioItemEmits, MenuRadioItemProps } from '../menu';
/**
* One option within a `ContextMenuRadioGroup`, identified by its `value`.
* Selecting it sets the group's value; pair it with a `ContextMenuItemIndicator`
* to render the selected dot.
*/
export interface ContextMenuRadioItemProps extends MenuRadioItemProps {}
export type ContextMenuRadioItemEmits = MenuRadioItemEmits;
</script>
@@ -1,43 +1,46 @@
<script lang="ts">
import type { Direction } from '../config-provider';
/**
* A menu that opens at the pointer on right-click (or a long-press on touch),
* replacing the platform's native context menu with your own styled actions.
* Built on top of Menu, so it inherits keyboard navigation, typeahead, nested
* submenus, and checkbox/radio items.
*
* Use it for contextual actions tied to a region or element — cut/copy/paste,
* row actions in a table, canvas tools — when there is no persistent button to
* click. The root owns open state and provides context to every part; listen
* to `update:open` to react when the menu opens or closes.
*/
export interface ContextMenuRootProps {
dir?: Direction;
modal?: boolean;
}
export interface ContextMenuRootEmits {
'update:open': [value: boolean];
}
</script>
<script setup lang="ts">
import { ref, toRef } from 'vue';
import { toRef } from 'vue';
import { MenuRoot } from '../menu';
import { provideContextMenuRootContext } from './context';
const { dir, modal = true } = defineProps<ContextMenuRootProps>();
const emit = defineEmits<ContextMenuRootEmits>();
defineSlots<{ default?: (props: { open: boolean }) => unknown }>();
const open = ref(false);
const open = defineModel<boolean>('open', { default: false });
provideContextMenuRootContext({
open,
onOpenChange: (v) => {
open.value = v;
emit('update:open', v);
},
onOpenChange: (v) => { open.value = v; },
modal: toRef(() => modal),
});
</script>
<template>
<MenuRoot
:open="open"
v-model:open="open"
:dir="dir"
:modal="modal"
@update:open="open = $event"
>
<slot :open="open" />
</MenuRoot>
@@ -1,6 +1,10 @@
<script lang="ts">
import type { MenuSeparatorProps } from '../menu';
/**
* A visual divider between groups of items, exposed as `role="separator"` and
* skipped by keyboard navigation.
*/
export interface ContextMenuSeparatorProps extends MenuSeparatorProps {}
</script>
@@ -1,6 +1,11 @@
<script lang="ts">
import type { MenuSubEmits, MenuSubProps } from '../menu';
/**
* Wraps a nested submenu, pairing a `ContextMenuSubTrigger` with its
* `ContextMenuSubContent` and owning that submenu's open state. Listen to
* `update:open` to track expansion.
*/
export interface ContextMenuSubProps extends MenuSubProps {}
export type ContextMenuSubEmits = MenuSubEmits;
</script>
@@ -1,6 +1,10 @@
<script lang="ts">
import type { MenuSubContentEmits, MenuSubContentProps } from '../menu';
/**
* The floating panel of a nested submenu, positioned alongside its
* `ContextMenuSubTrigger`. Render it inside a `ContextMenuSub`.
*/
export interface ContextMenuSubContentProps extends MenuSubContentProps {}
export type ContextMenuSubContentEmits = MenuSubContentEmits;
</script>
@@ -1,6 +1,10 @@
<script lang="ts">
import type { MenuSubTriggerProps } from '../menu';
/**
* The item that opens a nested submenu on hover or arrow-key, anchoring its
* `ContextMenuSubContent`. Place it as the first child of a `ContextMenuSub`.
*/
export interface ContextMenuSubTriggerProps extends MenuSubTriggerProps {}
</script>
@@ -1,6 +1,11 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* The region that captures right-click (and touch long-press), preventing the
* native context menu and opening the menu anchored at the pointer position.
* Wrap whatever area should respond to a secondary click.
*/
export interface ContextMenuTriggerProps extends PrimitiveProps {
disabled?: boolean;
}
+183
View File
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuItem,
ContextMenuItemIndicator,
ContextMenuLabel,
ContextMenuPortal,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuRoot,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from '@robonen/primitives';
const showGrid = ref(true);
const showRulers = ref(false);
const zoom = ref('100');
const lastAction = ref('Right-click the canvas to open the menu.');
function run(action: string) {
lastAction.value = action;
}
const itemClass = 'flex items-center justify-between gap-6 rounded px-2 py-1.5 text-sm outline-none cursor-default select-none data-[highlighted]:bg-(--accent) data-[highlighted]:text-(--accent-fg) data-[disabled]:opacity-40 data-[disabled]:pointer-events-none';
const contentClass = 'min-w-52 rounded-lg border border-(--border) bg-(--bg-elevated) p-1 text-(--fg) shadow-lg shadow-black/10';
</script>
<template>
<div class="flex flex-col items-center gap-3">
<ContextMenuRoot>
<ContextMenuTrigger
as="div"
class="flex h-44 w-80 items-center justify-center rounded-xl border border-dashed border-(--border) bg-(--bg-subtle) text-sm text-(--fg-muted) select-none"
>
Right-click anywhere in this area
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent :class="contentClass">
<ContextMenuItem
:class="itemClass"
@select="run('Cut')"
>
Cut
<span class="text-xs text-(--fg-subtle)">X</span>
</ContextMenuItem>
<ContextMenuItem
:class="itemClass"
@select="run('Copy')"
>
Copy
<span class="text-xs text-(--fg-subtle)">C</span>
</ContextMenuItem>
<ContextMenuItem
:class="itemClass"
disabled
>
Paste
<span class="text-xs text-(--fg-subtle)">V</span>
</ContextMenuItem>
<ContextMenuSeparator class="my-1 h-px bg-(--border)" />
<ContextMenuLabel class="px-2 py-1 text-xs font-medium text-(--fg-subtle)">
View
</ContextMenuLabel>
<ContextMenuCheckboxItem
v-model:checked="showGrid"
:class="itemClass"
>
<span class="flex items-center gap-2">
<span class="flex size-4 items-center justify-center">
<ContextMenuItemIndicator>
<svg
class="size-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</ContextMenuItemIndicator>
</span>
Show grid
</span>
</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem
v-model:checked="showRulers"
:class="itemClass"
>
<span class="flex items-center gap-2">
<span class="flex size-4 items-center justify-center">
<ContextMenuItemIndicator>
<svg
class="size-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</ContextMenuItemIndicator>
</span>
Show rulers
</span>
</ContextMenuCheckboxItem>
<ContextMenuSub>
<ContextMenuSubTrigger
:class="itemClass"
class="data-[state=open]:bg-(--bg-subtle)"
>
Zoom
<svg
class="size-4 text-(--fg-subtle)"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</ContextMenuSubTrigger>
<ContextMenuPortal>
<ContextMenuSubContent
:class="contentClass"
class="min-w-32"
>
<ContextMenuRadioGroup v-model="zoom">
<ContextMenuRadioItem
v-for="level in ['50', '100', '200']"
:key="level"
:value="level"
:class="itemClass"
>
<span class="flex items-center gap-2">
<span class="flex size-4 items-center justify-center">
<ContextMenuItemIndicator>
<span class="size-1.5 rounded-full bg-current" />
</ContextMenuItemIndicator>
</span>
{{ level }}%
</span>
</ContextMenuRadioItem>
</ContextMenuRadioGroup>
</ContextMenuSubContent>
</ContextMenuPortal>
</ContextMenuSub>
<ContextMenuSeparator class="my-1 h-px bg-(--border)" />
<ContextMenuItem
:class="itemClass"
class="text-red-600 data-[highlighted]:bg-red-600 data-[highlighted]:text-white dark:text-red-400"
@select="run('Delete')"
>
Delete
<span class="text-xs opacity-70"></span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot>
<p class="text-xs text-(--fg-muted)">
{{ lastAction }} · grid {{ showGrid ? 'on' : 'off' }} · zoom {{ zoom }}%
</p>
</div>
</template>
+1 -1
View File
@@ -9,7 +9,7 @@ export { default as ContextMenuLabel, type ContextMenuLabelProps } from './Conte
export { default as ContextMenuPortal, type ContextMenuPortalProps } from './ContextMenuPortal.vue';
export { default as ContextMenuRadioGroup, type ContextMenuRadioGroupEmits, type ContextMenuRadioGroupProps } from './ContextMenuRadioGroup.vue';
export { default as ContextMenuRadioItem, type ContextMenuRadioItemEmits, type ContextMenuRadioItemProps } from './ContextMenuRadioItem.vue';
export { default as ContextMenuRoot, type ContextMenuRootEmits, type ContextMenuRootProps } from './ContextMenuRoot.vue';
export { default as ContextMenuRoot, type ContextMenuRootProps } from './ContextMenuRoot.vue';
export { default as ContextMenuSeparator, type ContextMenuSeparatorProps } from './ContextMenuSeparator.vue';
export { default as ContextMenuSub, type ContextMenuSubEmits, type ContextMenuSubProps } from './ContextMenuSub.vue';
export { default as ContextMenuSubContent, type ContextMenuSubContentEmits, type ContextMenuSubContentProps } from './ContextMenuSubContent.vue';
@@ -1,6 +1,11 @@
<script lang="ts">
import type { PopperAnchorProps } from '../popper';
/**
* Optional custom anchor for positioning the popover against an element other
* than the trigger (e.g. a field or input group). When present, the trigger
* stops acting as the anchor and the content is positioned relative to this.
*/
export interface DatePickerAnchorProps extends PopperAnchorProps {}
</script>
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PopperArrowProps } from '../popper';
/**
* An optional arrow rendered inside `DatePickerContent` that points back at the
* trigger/anchor. Purely decorative; place it as a child of the content.
*/
export interface DatePickerArrowProps extends PopperArrowProps {}
</script>
@@ -1,6 +1,12 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* A styling wrapper for the calendar grid rendered inside the popover. The
* calendar subparts (`DatePickerGrid`, `DatePickerCell`, etc.) consume the
* calendar context provided by `DatePickerRoot`; this element just groups and
* labels them with a `data-primitives-date-picker-calendar` hook.
*/
export interface DatePickerCalendarProps extends PrimitiveProps {}
</script>
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* A button that closes the picker popover when clicked. Render it inside
* `DatePickerContent` (e.g. a "Done" or dismiss action).
*/
export interface DatePickerCloseProps extends PrimitiveProps {}
</script>
@@ -3,6 +3,12 @@ import type { DismissableLayerEmits } from '../dismissable-layer';
import type { FocusScopeEmits } from '../focus-scope';
import type { PopperContentProps } from '../popper';
/**
* The popover panel that holds the calendar. Handles Popper positioning,
* presence (mount/unmount on open), focus trapping/restoration, and dismissal
* via Escape or outside interaction. Renders only while open unless `forceMount`
* is set.
*/
export interface DatePickerContentProps extends PopperContentProps {
/** Keep mounted for CSS exit animations. */
forceMount?: boolean;
@@ -1,6 +1,11 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* A text input that renders the selected date and, when `editable`, lets users
* type a date that is parsed and committed back to the picker on blur/Enter.
* Aliased as `DatePickerInput`; defaults to a read-only display of the value.
*/
export interface DatePickerFieldProps extends PrimitiveProps {
/** Allow typing into the field. @default false (read-only display) */
editable?: boolean;
@@ -1,6 +1,12 @@
<script lang="ts">
import type { TeleportPrimitiveProps } from '../teleport';
/**
* Teleports the popover content into a different part of the DOM (the body by
* default) so it escapes overflow/stacking-context clipping. Wrap
* `DatePickerContent` with it when the picker lives inside a scrolled or
* transformed container.
*/
export interface DatePickerPortalProps extends TeleportPrimitiveProps {}
</script>
@@ -2,6 +2,13 @@
import type { CalendarMonth, CalendarRootProps, WeekDayFormat } from '../calendar';
import type { PrimitiveProps } from '../primitive';
/**
* A single-date picker that pairs a popover-anchored calendar with an optional
* trigger, field, and hidden form input. Owns the selected date, placeholder
* month, and open state, and provides both date-picker and calendar context to
* its parts. Use it when you need a compact, accessible "pick one date" control
* (e.g. a form field) rather than an always-visible `Calendar`.
*/
export interface DatePickerRootProps extends PrimitiveProps,
Omit<CalendarRootProps, 'as' | 'asChild'> {
/** Uncontrolled initial open state. */
@@ -1,6 +1,11 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* The button that toggles the picker popover open and closed. Acts as the
* Popper anchor (unless a custom `DatePickerAnchor` is present) and carries the
* dialog-related ARIA wiring (`aria-haspopup`, `aria-expanded`, `aria-controls`).
*/
export interface DatePickerTriggerProps extends PrimitiveProps {}
</script>
+158
View File
@@ -0,0 +1,158 @@
<script lang="ts">
import { defineComponent, h } from 'vue';
import {
DatePickerCell,
DatePickerCellTrigger,
DatePickerGrid,
DatePickerGridBody,
DatePickerGridHead,
DatePickerGridRow,
DatePickerHeadCell,
useCalendarRootContext,
} from '@robonen/primitives';
// Reads the calendar context provided by DatePickerRoot. Defined as a child so
// the injection resolves (the demo's own <script setup> is the Root's parent).
const CalendarBody = defineComponent({
name: 'CalendarBody',
setup() {
const ctx = useCalendarRootContext();
return () => ctx.grid.value.map(month => h(
DatePickerGrid,
{ key: month.value.toString(), month: month.value, class: 'w-full border-collapse select-none' },
() => [
h(DatePickerGridHead, null, () => h(
DatePickerGridRow,
{ class: 'mb-1 flex' },
() => ctx.weekDays.value.map((weekday, i) => h(
DatePickerHeadCell,
{ key: weekday + i, class: 'w-9 text-center text-xs font-medium text-(--fg-subtle)' },
() => weekday,
)),
)),
h(DatePickerGridBody, null, () => month.weeks.map((week, w) => h(
DatePickerGridRow,
{ key: w, class: 'flex w-full' },
() => week.map(day => h(
DatePickerCell,
{ key: day.toString(), date: day, class: 'p-0.5' },
() => h(
DatePickerCellTrigger,
{
day,
month: month.value,
class: `flex size-8 items-center justify-center rounded-lg text-sm tabular-nums transition outline-none cursor-pointer
focus-visible:ring-2 focus-visible:ring-(--ring)
hover:bg-(--bg-inset)
data-[selected]:bg-(--accent) data-[selected]:font-semibold data-[selected]:text-(--accent-fg) data-[selected]:hover:bg-(--accent-hover)
data-[outside-view]:text-(--fg-subtle) data-[outside-view]:opacity-50
data-[disabled]:cursor-not-allowed data-[disabled]:opacity-30`,
},
),
)),
))),
],
));
},
});
export default CalendarBody;
</script>
<script setup lang="ts">
import {
DatePickerCalendar,
DatePickerClose,
DatePickerContent,
DatePickerField,
DatePickerHeading,
DatePickerNext,
DatePickerPrev,
DatePickerRoot,
DatePickerTrigger,
} from '@robonen/primitives';
import { ref } from 'vue';
const value = ref<Date>();
</script>
<template>
<div class="flex w-full max-w-xs flex-col gap-2">
<span class="text-xs font-medium text-(--fg-muted)">Departure date</span>
<DatePickerRoot v-slot="{ open }" v-model="value" :close-on-select="true">
<div class="flex items-stretch gap-1.5">
<DatePickerField
:format="{ weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }"
placeholder-text="Select a date"
class="min-w-0 flex-1 rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) outline-none placeholder:text-(--fg-subtle) focus-visible:ring-2 focus-visible:ring-(--ring)"
/>
<DatePickerTrigger
aria-label="Open calendar"
class="inline-flex size-9 shrink-0 items-center justify-center rounded-lg border border-(--border) bg-(--bg) text-(--fg-muted) transition hover:bg-(--bg-inset) hover:text-(--fg) active:scale-95 cursor-pointer data-[state=open]:bg-(--bg-inset) data-[state=open]:text-(--fg)"
>
<svg
class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</svg>
</DatePickerTrigger>
</div>
<DatePickerContent
:side-offset="6"
class="z-50 rounded-xl border border-(--border) bg-(--bg-elevated) p-3 text-(--fg) shadow-lg data-[state=closed]:opacity-0"
>
<DatePickerCalendar>
<div class="mb-3 flex items-center justify-between gap-2">
<DatePickerPrev
aria-label="Previous month"
class="inline-flex size-8 items-center justify-center rounded-lg border border-(--border) bg-(--bg) text-(--fg-muted) transition hover:bg-(--bg-inset) hover:text-(--fg) active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
>
</DatePickerPrev>
<DatePickerHeading class="text-sm font-semibold tracking-tight" />
<DatePickerNext
aria-label="Next month"
class="inline-flex size-8 items-center justify-center rounded-lg border border-(--border) bg-(--bg) text-(--fg-muted) transition hover:bg-(--bg-inset) hover:text-(--fg) active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
>
</DatePickerNext>
</div>
<CalendarBody />
</DatePickerCalendar>
<div class="mt-3 flex items-center justify-between border-t border-(--border) pt-3">
<button
type="button"
class="rounded-md px-2 py-1 text-xs font-medium text-(--fg-muted) transition hover:bg-(--bg-inset) hover:text-(--fg) cursor-pointer"
@click="value = undefined"
>
Clear
</button>
<DatePickerClose
class="rounded-md bg-(--accent) px-3 py-1 text-xs font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-95 cursor-pointer"
>
Done
</DatePickerClose>
</div>
</DatePickerContent>
<p v-if="false">{{ open }}</p>
</DatePickerRoot>
<p class="text-xs text-(--fg-subtle)">
<template v-if="value">
Selected
<span class="font-medium text-(--fg-muted)">{{ value.toLocaleDateString('en', { dateStyle: 'medium' }) }}</span>
</template>
<template v-else>
No date selected yet
</template>
</p>
</div>
</template>
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* A button that closes the dialog when activated. Place inside Content for an
* explicit dismiss control (e.g. an "X" in the corner or a "Cancel" button).
*/
export interface DialogCloseProps extends PrimitiveProps {}
</script>
@@ -1,6 +1,13 @@
<script lang="ts">
import type { DialogContentImplEmits, DialogContentImplProps } from './DialogContentImpl.vue';
/**
* The dialog panel itself — the container for Title, Description, and the body.
* Renders only while open and picks a modal or non-modal implementation from
* the Root's `modal` setting: modal traps focus, locks body scroll, and hides
* the rest of the page from assistive tech; non-modal does none of these. Emits
* focus and dismissal events so consumers can guard against closing.
*/
export interface DialogContentProps extends Omit<DialogContentImplProps, 'trapFocus' | 'disableOutsidePointerEvents'> {
/** Keep mounted for CSS exit animations. */
forceMount?: boolean;
@@ -3,6 +3,11 @@ import type { DismissableLayerEmits } from '../dismissable-layer';
import type { FocusScopeEmits } from '../focus-scope';
import type { PrimitiveProps } from '../primitive';
/**
* Internal shared implementation behind DialogContent — wraps a FocusScope and
* a DismissableLayer and applies the dialog ARIA wiring. Not exported; the modal
* and non-modal Content variants render this with the appropriate flags.
*/
export interface DialogContentImplProps extends PrimitiveProps {
/** Trap focus inside the content (modal dialogs). */
trapFocus?: boolean;
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* An optional supporting description for the dialog. Its id is wired to the
* Content's `aria-describedby` so screen readers announce it after the title.
*/
export interface DialogDescriptionProps extends PrimitiveProps {}
</script>
@@ -1,6 +1,11 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* A full-screen layer rendered behind the Content that dims and covers the page
* while a modal dialog is open. Only renders in modal mode; omit it for
* non-modal dialogs.
*/
export interface DialogOverlayProps extends PrimitiveProps {
/**
* Keep overlay mounted even when the dialog is closed — useful for CSS
@@ -1,6 +1,11 @@
<script lang="ts">
import type { TeleportPrimitiveProps } from '../teleport';
/**
* Teleports the Overlay and Content out of the normal DOM flow (by default into
* `body`) so they render above the rest of the page and escape `overflow`/
* stacking contexts. Mounts its children only while the dialog is open.
*/
export interface DialogPortalProps extends TeleportPrimitiveProps {
/**
* When true the Portal (and its descendants) remain mounted even when the

Some files were not shown because too many files have changed in this diff Show More