Merge pull request #141 from robonen/docs
feat(forms): add useMaskedField and useMaskedInput composables for in…
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 1–2 business days and arrive in 3–5 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 9–5 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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -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). */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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%);"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user