@xelqun/forms 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +116 -2
- package/package.json +56 -5
- package/resources/css/xelqun.css +162 -0
- package/resources/js/components/XqFieldWrapper.vue +59 -0
- package/resources/js/components/XqForm.vue +128 -0
- package/resources/js/components/XqNode.vue +22 -0
- package/resources/js/components/XqUnknownNode.vue +15 -0
- package/resources/js/components/defaults.ts +44 -0
- package/resources/js/components/fields/XqCheckboxField.vue +48 -0
- package/resources/js/components/fields/XqDatePickerField.vue +96 -0
- package/resources/js/components/fields/XqFileUploadField.vue +247 -0
- package/resources/js/components/fields/XqRadioField.vue +45 -0
- package/resources/js/components/fields/XqRepeaterField.vue +166 -0
- package/resources/js/components/fields/XqSelectField.vue +151 -0
- package/resources/js/components/fields/XqTextInputField.vue +100 -0
- package/resources/js/components/fields/XqTextareaField.vue +28 -0
- package/resources/js/components/fields/XqToggleField.vue +47 -0
- package/resources/js/components/layouts/XqFieldset.vue +16 -0
- package/resources/js/components/layouts/XqGrid.vue +13 -0
- package/resources/js/components/layouts/XqSection.vue +62 -0
- package/resources/js/components/layouts/XqTabsLayout.vue +64 -0
- package/resources/js/composables/context.ts +22 -0
- package/resources/js/composables/layout.ts +55 -0
- package/resources/js/composables/registry.ts +25 -0
- package/resources/js/composables/useField.ts +41 -0
- package/resources/js/composables/useXqForm.ts +16 -0
- package/resources/js/core/conditions.ts +52 -0
- package/resources/js/core/expressions.ts +131 -0
- package/resources/js/core/http.ts +101 -0
- package/resources/js/core/log.ts +93 -0
- package/resources/js/core/patch.ts +65 -0
- package/resources/js/core/paths.ts +122 -0
- package/resources/js/core/queue.ts +190 -0
- package/resources/js/core/rows.ts +26 -0
- package/resources/js/core/store.ts +560 -0
- package/resources/js/core/values.ts +161 -0
- package/resources/js/index.ts +24 -0
- package/resources/js/lib/utils.ts +18 -0
- package/resources/js/plugin.ts +17 -0
- package/resources/js/schema/types.ts +99 -0
- package/resources/js/ui/XqBadge.vue +33 -0
- package/resources/js/ui/XqButton.vue +59 -0
- package/resources/js/ui/XqCalendar.vue +90 -0
- package/resources/js/ui/XqCard.vue +20 -0
- package/resources/js/ui/XqCheckbox.vue +28 -0
- package/resources/js/ui/XqCombobox.vue +184 -0
- package/resources/js/ui/XqInput.vue +16 -0
- package/resources/js/ui/XqLabel.vue +17 -0
- package/resources/js/ui/XqRadioGroup.vue +24 -0
- package/resources/js/ui/XqRadioGroupItem.vue +31 -0
- package/resources/js/ui/XqSelect.vue +79 -0
- package/resources/js/ui/XqSwitch.vue +27 -0
- package/resources/js/ui/XqTabs.vue +19 -0
- package/resources/js/ui/XqTabsContent.vue +17 -0
- package/resources/js/ui/XqTabsList.vue +21 -0
- package/resources/js/ui/XqTabsTrigger.vue +27 -0
- package/resources/js/ui/XqTextarea.vue +16 -0
- package/resources/js/ui/classes.ts +12 -0
- package/resources/js/ui/index.ts +17 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export { createXelqunForms } from './plugin';
|
|
2
|
+
export type { XelqunFormsOptions } from './plugin';
|
|
3
|
+
|
|
4
|
+
export { default as XqForm } from './components/XqForm.vue';
|
|
5
|
+
export { default as XqNode } from './components/XqNode.vue';
|
|
6
|
+
export { default as XqFieldWrapper } from './components/XqFieldWrapper.vue';
|
|
7
|
+
export { createRegistry, defaultComponents } from './components/defaults';
|
|
8
|
+
|
|
9
|
+
export { useXqForm } from './composables/useXqForm';
|
|
10
|
+
export { useField } from './composables/useField';
|
|
11
|
+
export { useFormStore } from './composables/context';
|
|
12
|
+
export { gridStyle, spanStyle } from './composables/layout';
|
|
13
|
+
export type { ComponentResolver } from './composables/registry';
|
|
14
|
+
|
|
15
|
+
export { createFormStore, defaultLiveConfig } from './core/store';
|
|
16
|
+
export type { FormStore, FormStoreOptions, SubmitOptions } from './core/store';
|
|
17
|
+
export type { RequestKind, RequestLogEntry } from './core/log';
|
|
18
|
+
export { evaluateCondition } from './core/conditions';
|
|
19
|
+
export { evaluateExpression } from './core/expressions';
|
|
20
|
+
export { getPath, overlaps } from './core/paths';
|
|
21
|
+
|
|
22
|
+
export * from './schema/types';
|
|
23
|
+
export * from './ui';
|
|
24
|
+
export { cn } from './lib/utils';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { clsx } from 'clsx';
|
|
2
|
+
import type { ClassValue } from 'clsx';
|
|
3
|
+
import { twMerge } from 'tailwind-merge';
|
|
4
|
+
|
|
5
|
+
export function cn(...inputs: ClassValue[]): string {
|
|
6
|
+
return twMerge(clsx(inputs));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function formatBytes(bytes: number | null | undefined): string {
|
|
10
|
+
if (!bytes) {
|
|
11
|
+
return '';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
15
|
+
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
16
|
+
|
|
17
|
+
return `${(bytes / 1024 ** exponent).toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`;
|
|
18
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Plugin } from 'vue';
|
|
2
|
+
import { createRegistry } from './components/defaults';
|
|
3
|
+
import { REGISTRY } from './composables/registry';
|
|
4
|
+
import type { ComponentResolver } from './composables/registry';
|
|
5
|
+
|
|
6
|
+
export interface XelqunFormsOptions {
|
|
7
|
+
/** Extra or replacement renderers keyed by schema type, e.g. { rating: () => import('./Rating.vue') }. */
|
|
8
|
+
components?: Record<string, ComponentResolver>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createXelqunForms(options: XelqunFormsOptions = {}): Plugin {
|
|
12
|
+
return {
|
|
13
|
+
install(app) {
|
|
14
|
+
app.provide(REGISTRY, createRegistry(options.components));
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript mirror of the JSON schema contract produced by the PHP package.
|
|
3
|
+
* Bump SUPPORTED_SCHEMA_VERSIONS together with Xelqun\Forms\Schema\SchemaVersion.
|
|
4
|
+
*/
|
|
5
|
+
export const SUPPORTED_SCHEMA_VERSIONS = [1] as const;
|
|
6
|
+
|
|
7
|
+
export type ConditionNode =
|
|
8
|
+
| { op: 'and' | 'or' | 'not'; args: ConditionNode[] }
|
|
9
|
+
| {
|
|
10
|
+
op: 'eq' | 'neq' | 'in' | 'notIn' | 'gt' | 'gte' | 'lt' | 'lte';
|
|
11
|
+
path: string;
|
|
12
|
+
value: unknown;
|
|
13
|
+
}
|
|
14
|
+
| { op: 'filled' | 'blank'; path: string };
|
|
15
|
+
|
|
16
|
+
export type ExpressionNode =
|
|
17
|
+
| { lit: unknown }
|
|
18
|
+
| { ref: string }
|
|
19
|
+
| { op: string; args: ExpressionNode[] }
|
|
20
|
+
| { fn: string; args: ExpressionNode[] };
|
|
21
|
+
|
|
22
|
+
export type LiveMode = 'change' | 'blur' | 'debounce';
|
|
23
|
+
|
|
24
|
+
export interface LiveConfig {
|
|
25
|
+
mode: LiveMode;
|
|
26
|
+
ms: number | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type Breakpoint = 'default' | 'sm' | 'md' | 'lg' | 'xl';
|
|
30
|
+
|
|
31
|
+
export type ColumnSpan = number | 'full' | Partial<Record<Breakpoint, number | 'full'>> | null;
|
|
32
|
+
|
|
33
|
+
export interface SchemaNode {
|
|
34
|
+
key: string;
|
|
35
|
+
type: string;
|
|
36
|
+
hidden: boolean;
|
|
37
|
+
path?: string | null;
|
|
38
|
+
label?: string | null;
|
|
39
|
+
hint?: string | null;
|
|
40
|
+
placeholder?: string | null;
|
|
41
|
+
required?: boolean;
|
|
42
|
+
disabled?: boolean;
|
|
43
|
+
rules?: {
|
|
44
|
+
visible?: ConditionNode | null;
|
|
45
|
+
required?: ConditionNode | null;
|
|
46
|
+
disabled?: ConditionNode | null;
|
|
47
|
+
};
|
|
48
|
+
computed?: { src: string; ast: ExpressionNode } | null;
|
|
49
|
+
live?: LiveConfig | null;
|
|
50
|
+
span?: ColumnSpan;
|
|
51
|
+
props?: Record<string, any>;
|
|
52
|
+
children?: SchemaNode[];
|
|
53
|
+
deps?: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface FormEndpoints {
|
|
57
|
+
update: string;
|
|
58
|
+
upload: string;
|
|
59
|
+
search: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface FormSchema {
|
|
63
|
+
$xq: number;
|
|
64
|
+
pkg: string;
|
|
65
|
+
id: string;
|
|
66
|
+
token: string | null;
|
|
67
|
+
endpoints: FormEndpoints;
|
|
68
|
+
state: { data: Record<string, any> };
|
|
69
|
+
components: SchemaNode[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface UpdateRequest {
|
|
73
|
+
token: string;
|
|
74
|
+
seq: number;
|
|
75
|
+
state: { data: Record<string, any> };
|
|
76
|
+
changed: string[];
|
|
77
|
+
deps: Record<string, string[]>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface UpdatePatch {
|
|
81
|
+
$xq: number;
|
|
82
|
+
seq: number;
|
|
83
|
+
set: Record<string, unknown>;
|
|
84
|
+
schema: Record<string, Partial<SchemaNode>>;
|
|
85
|
+
errors: Record<string, string | string[]>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface SelectOption {
|
|
89
|
+
value: string | number;
|
|
90
|
+
label: string;
|
|
91
|
+
description?: string | null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface UploadResult {
|
|
95
|
+
token: string;
|
|
96
|
+
name: string;
|
|
97
|
+
size: number;
|
|
98
|
+
mime: string | null;
|
|
99
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { cva } from 'class-variance-authority';
|
|
3
|
+
import type { VariantProps } from 'class-variance-authority';
|
|
4
|
+
import type { HTMLAttributes } from 'vue';
|
|
5
|
+
import { cn } from '../lib/utils';
|
|
6
|
+
|
|
7
|
+
const badgeVariants = cva(
|
|
8
|
+
'gap-1 px-2 py-0.5 text-xs font-medium [&>svg]:size-3 inline-flex w-fit shrink-0 items-center rounded-full border whitespace-nowrap',
|
|
9
|
+
{
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
default: 'border-transparent bg-xq-primary text-xq-primary-foreground',
|
|
13
|
+
secondary: 'border-transparent bg-xq-secondary text-xq-secondary-foreground',
|
|
14
|
+
outline: 'border-xq-border text-xq-foreground',
|
|
15
|
+
success: 'border-transparent bg-xq-success/15 text-xq-success',
|
|
16
|
+
destructive: 'border-transparent bg-xq-destructive/15 text-xq-destructive',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
defaultVariants: { variant: 'default' },
|
|
20
|
+
},
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const props = defineProps<{
|
|
24
|
+
variant?: VariantProps<typeof badgeVariants>['variant'];
|
|
25
|
+
class?: HTMLAttributes['class'];
|
|
26
|
+
}>();
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<template>
|
|
30
|
+
<span data-slot="xq-badge" :class="cn(badgeVariants({ variant }), props.class)">
|
|
31
|
+
<slot />
|
|
32
|
+
</span>
|
|
33
|
+
</template>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { cva } from 'class-variance-authority';
|
|
3
|
+
import type { VariantProps } from 'class-variance-authority';
|
|
4
|
+
import { Primitive } from 'reka-ui';
|
|
5
|
+
import type { PrimitiveProps } from 'reka-ui';
|
|
6
|
+
import type { HTMLAttributes } from 'vue';
|
|
7
|
+
import { cn } from '../lib/utils';
|
|
8
|
+
|
|
9
|
+
const buttonVariants = cva(
|
|
10
|
+
'gap-2 text-sm font-medium [&_svg:not([class*=size-])]:size-4 inline-flex shrink-0 cursor-pointer items-center justify-center rounded-xq-md whitespace-nowrap transition-[color,background-color,box-shadow,opacity] outline-none focus-visible:ring-[3px] focus-visible:ring-xq-ring/40 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
|
11
|
+
{
|
|
12
|
+
variants: {
|
|
13
|
+
variant: {
|
|
14
|
+
default:
|
|
15
|
+
'shadow-xs bg-xq-primary text-xq-primary-foreground hover:bg-xq-primary/90',
|
|
16
|
+
secondary: 'bg-xq-secondary text-xq-secondary-foreground hover:bg-xq-secondary/80',
|
|
17
|
+
outline:
|
|
18
|
+
'shadow-xs border border-xq-border bg-xq-background text-xq-foreground hover:bg-xq-accent hover:text-xq-accent-foreground',
|
|
19
|
+
ghost: 'text-xq-foreground hover:bg-xq-accent hover:text-xq-accent-foreground',
|
|
20
|
+
destructive:
|
|
21
|
+
'shadow-xs bg-xq-destructive text-xq-destructive-foreground hover:bg-xq-destructive/90',
|
|
22
|
+
link: 'text-xq-primary underline-offset-4 hover:underline',
|
|
23
|
+
},
|
|
24
|
+
size: {
|
|
25
|
+
default: 'h-9 px-4 py-2',
|
|
26
|
+
sm: 'h-8 gap-1.5 px-3 text-xs',
|
|
27
|
+
lg: 'h-10 px-6',
|
|
28
|
+
icon: 'size-9',
|
|
29
|
+
'icon-sm': 'size-7',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
defaultVariants: { variant: 'default', size: 'default' },
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
type ButtonVariants = VariantProps<typeof buttonVariants>;
|
|
37
|
+
|
|
38
|
+
const props = withDefaults(
|
|
39
|
+
defineProps<
|
|
40
|
+
PrimitiveProps & {
|
|
41
|
+
variant?: ButtonVariants['variant'];
|
|
42
|
+
size?: ButtonVariants['size'];
|
|
43
|
+
class?: HTMLAttributes['class'];
|
|
44
|
+
}
|
|
45
|
+
>(),
|
|
46
|
+
{ as: 'button' },
|
|
47
|
+
);
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<template>
|
|
51
|
+
<Primitive
|
|
52
|
+
data-slot="xq-button"
|
|
53
|
+
:as="as"
|
|
54
|
+
:as-child="asChild"
|
|
55
|
+
:class="cn(buttonVariants({ variant, size }), props.class)"
|
|
56
|
+
>
|
|
57
|
+
<slot />
|
|
58
|
+
</Primitive>
|
|
59
|
+
</template>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { DateValue } from '@internationalized/date';
|
|
3
|
+
import { ChevronLeft, ChevronRight } from 'lucide-vue-next';
|
|
4
|
+
import {
|
|
5
|
+
CalendarCell,
|
|
6
|
+
CalendarCellTrigger,
|
|
7
|
+
CalendarGrid,
|
|
8
|
+
CalendarGridBody,
|
|
9
|
+
CalendarGridHead,
|
|
10
|
+
CalendarGridRow,
|
|
11
|
+
CalendarHeadCell,
|
|
12
|
+
CalendarHeader,
|
|
13
|
+
CalendarHeading,
|
|
14
|
+
CalendarNext,
|
|
15
|
+
CalendarPrev,
|
|
16
|
+
CalendarRoot,
|
|
17
|
+
} from 'reka-ui';
|
|
18
|
+
import type { HTMLAttributes } from 'vue';
|
|
19
|
+
import { cn } from '../lib/utils';
|
|
20
|
+
|
|
21
|
+
const props = defineProps<{
|
|
22
|
+
minValue?: DateValue;
|
|
23
|
+
maxValue?: DateValue;
|
|
24
|
+
placeholder?: DateValue;
|
|
25
|
+
class?: HTMLAttributes['class'];
|
|
26
|
+
}>();
|
|
27
|
+
const model = defineModel<DateValue | undefined>();
|
|
28
|
+
|
|
29
|
+
const navClass =
|
|
30
|
+
'inline-flex size-7 cursor-pointer items-center justify-center rounded-xq-md border border-xq-border bg-transparent opacity-70 hover:bg-xq-accent hover:opacity-100 disabled:pointer-events-none disabled:opacity-30';
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<CalendarRoot
|
|
35
|
+
v-slot="{ grid, weekDays }"
|
|
36
|
+
v-model="model as any"
|
|
37
|
+
:min-value="minValue"
|
|
38
|
+
:max-value="maxValue"
|
|
39
|
+
:placeholder="placeholder"
|
|
40
|
+
fixed-weeks
|
|
41
|
+
weekday-format="short"
|
|
42
|
+
data-slot="xq-calendar"
|
|
43
|
+
:class="cn('p-3', props.class)"
|
|
44
|
+
>
|
|
45
|
+
<CalendarHeader class="relative flex w-full items-center justify-between">
|
|
46
|
+
<CalendarPrev :class="navClass"><ChevronLeft class="size-4" /></CalendarPrev>
|
|
47
|
+
<CalendarHeading class="text-sm font-medium" />
|
|
48
|
+
<CalendarNext :class="navClass"><ChevronRight class="size-4" /></CalendarNext>
|
|
49
|
+
</CalendarHeader>
|
|
50
|
+
<div class="mt-3 gap-y-4 flex flex-col">
|
|
51
|
+
<CalendarGrid
|
|
52
|
+
v-for="month in grid"
|
|
53
|
+
:key="month.value.toString()"
|
|
54
|
+
class="w-full border-collapse"
|
|
55
|
+
>
|
|
56
|
+
<CalendarGridHead>
|
|
57
|
+
<CalendarGridRow class="flex">
|
|
58
|
+
<CalendarHeadCell
|
|
59
|
+
v-for="day in weekDays"
|
|
60
|
+
:key="day"
|
|
61
|
+
class="w-8 font-normal text-[0.75rem] text-xq-muted-foreground"
|
|
62
|
+
>
|
|
63
|
+
{{ day.slice(0, 2) }}
|
|
64
|
+
</CalendarHeadCell>
|
|
65
|
+
</CalendarGridRow>
|
|
66
|
+
</CalendarGridHead>
|
|
67
|
+
<CalendarGridBody>
|
|
68
|
+
<CalendarGridRow
|
|
69
|
+
v-for="(weekDates, index) in month.rows"
|
|
70
|
+
:key="index"
|
|
71
|
+
class="mt-1 flex w-full"
|
|
72
|
+
>
|
|
73
|
+
<CalendarCell
|
|
74
|
+
v-for="weekDate in weekDates"
|
|
75
|
+
:key="weekDate.toString()"
|
|
76
|
+
:date="weekDate"
|
|
77
|
+
class="p-0 text-sm relative text-center"
|
|
78
|
+
>
|
|
79
|
+
<CalendarCellTrigger
|
|
80
|
+
:day="weekDate"
|
|
81
|
+
:month="month.value"
|
|
82
|
+
class="size-8 text-sm font-normal data-[today]:font-semibold inline-flex cursor-pointer items-center justify-center rounded-xq-md outline-none hover:bg-xq-accent focus-visible:ring-[3px] focus-visible:ring-xq-ring/30 data-[disabled]:pointer-events-none data-[disabled]:opacity-30 data-[outside-view]:text-xq-muted-foreground/60 data-[selected]:bg-xq-primary data-[selected]:text-xq-primary-foreground data-[today]:text-xq-primary data-[selected]:data-[today]:text-xq-primary-foreground data-[unavailable]:line-through"
|
|
83
|
+
/>
|
|
84
|
+
</CalendarCell>
|
|
85
|
+
</CalendarGridRow>
|
|
86
|
+
</CalendarGridBody>
|
|
87
|
+
</CalendarGrid>
|
|
88
|
+
</div>
|
|
89
|
+
</CalendarRoot>
|
|
90
|
+
</template>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from 'vue';
|
|
3
|
+
import { cn } from '../lib/utils';
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{ class?: HTMLAttributes['class'] }>();
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<div
|
|
10
|
+
data-slot="xq-card"
|
|
11
|
+
:class="
|
|
12
|
+
cn(
|
|
13
|
+
'shadow-xs rounded-xq-lg border border-xq-border bg-xq-card text-xq-card-foreground',
|
|
14
|
+
props.class,
|
|
15
|
+
)
|
|
16
|
+
"
|
|
17
|
+
>
|
|
18
|
+
<slot />
|
|
19
|
+
</div>
|
|
20
|
+
</template>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Check } from 'lucide-vue-next';
|
|
3
|
+
import { CheckboxIndicator, CheckboxRoot } from 'reka-ui';
|
|
4
|
+
import type { HTMLAttributes } from 'vue';
|
|
5
|
+
import { cn } from '../lib/utils';
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{ id?: string; disabled?: boolean; class?: HTMLAttributes['class'] }>();
|
|
8
|
+
const model = defineModel<boolean>({ default: false });
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<CheckboxRoot
|
|
13
|
+
:id="id"
|
|
14
|
+
v-model="model"
|
|
15
|
+
data-slot="xq-checkbox"
|
|
16
|
+
:disabled="disabled"
|
|
17
|
+
:class="
|
|
18
|
+
cn(
|
|
19
|
+
'peer size-4 shadow-xs shrink-0 cursor-pointer rounded-[4px] border border-xq-input bg-xq-background transition-shadow outline-none focus-visible:ring-[3px] focus-visible:ring-xq-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-xq-destructive data-[state=checked]:border-xq-primary data-[state=checked]:bg-xq-primary data-[state=checked]:text-xq-primary-foreground',
|
|
20
|
+
props.class,
|
|
21
|
+
)
|
|
22
|
+
"
|
|
23
|
+
>
|
|
24
|
+
<CheckboxIndicator class="flex items-center justify-center text-current">
|
|
25
|
+
<Check class="size-3.5" :stroke-width="3" />
|
|
26
|
+
</CheckboxIndicator>
|
|
27
|
+
</CheckboxRoot>
|
|
28
|
+
</template>
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Check, ChevronsUpDown, LoaderCircle, X } from 'lucide-vue-next';
|
|
3
|
+
import {
|
|
4
|
+
ComboboxAnchor,
|
|
5
|
+
ComboboxContent,
|
|
6
|
+
ComboboxEmpty,
|
|
7
|
+
ComboboxInput,
|
|
8
|
+
ComboboxItem,
|
|
9
|
+
ComboboxItemIndicator,
|
|
10
|
+
ComboboxPortal,
|
|
11
|
+
ComboboxRoot,
|
|
12
|
+
ComboboxTrigger,
|
|
13
|
+
ComboboxViewport,
|
|
14
|
+
} from 'reka-ui';
|
|
15
|
+
import { computed, reactive, ref, watch } from 'vue';
|
|
16
|
+
import type { HTMLAttributes } from 'vue';
|
|
17
|
+
import { cn } from '../lib/utils';
|
|
18
|
+
import type { SelectOption } from '../schema/types';
|
|
19
|
+
import { controlClass, optionClass, popoverClass } from './classes';
|
|
20
|
+
|
|
21
|
+
type Value = string | number;
|
|
22
|
+
|
|
23
|
+
const props = defineProps<{
|
|
24
|
+
id?: string;
|
|
25
|
+
options: SelectOption[];
|
|
26
|
+
multiple?: boolean;
|
|
27
|
+
remote?: boolean;
|
|
28
|
+
loading?: boolean;
|
|
29
|
+
placeholder?: string | null;
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
invalid?: boolean;
|
|
32
|
+
describedBy?: string;
|
|
33
|
+
searchFailed?: boolean;
|
|
34
|
+
class?: HTMLAttributes['class'];
|
|
35
|
+
}>();
|
|
36
|
+
const emit = defineEmits<{ search: [term: string]; blur: [] }>();
|
|
37
|
+
const model = defineModel<Value | Value[] | null>();
|
|
38
|
+
const searchTerm = ref('');
|
|
39
|
+
const labels = reactive(new Map<string, string>());
|
|
40
|
+
|
|
41
|
+
watch(
|
|
42
|
+
() => props.options,
|
|
43
|
+
(options) => options.forEach((option) => labels.set(String(option.value), option.label)),
|
|
44
|
+
{ immediate: true, deep: true },
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const selectedValues = computed<Value[]>(() => {
|
|
48
|
+
if (Array.isArray(model.value)) {
|
|
49
|
+
return model.value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return model.value === null || model.value === undefined || model.value === ''
|
|
53
|
+
? []
|
|
54
|
+
: [model.value];
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
function labelFor(value: unknown): string {
|
|
58
|
+
if (value === null || value === undefined || value === '') {
|
|
59
|
+
return '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return labels.get(String(value)) ?? String(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function remove(value: Value): void {
|
|
66
|
+
model.value = selectedValues.value.filter((item) => item !== value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function clear(): void {
|
|
70
|
+
model.value = props.multiple ? [] : null;
|
|
71
|
+
searchTerm.value = '';
|
|
72
|
+
}
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<template>
|
|
76
|
+
<ComboboxRoot
|
|
77
|
+
v-model="model as any"
|
|
78
|
+
:multiple="multiple"
|
|
79
|
+
:disabled="disabled"
|
|
80
|
+
:ignore-filter="remote"
|
|
81
|
+
open-on-click
|
|
82
|
+
:reset-search-term-on-blur="!multiple"
|
|
83
|
+
@update:open="(open) => !open && emit('blur')"
|
|
84
|
+
>
|
|
85
|
+
<ComboboxAnchor
|
|
86
|
+
data-slot="xq-combobox"
|
|
87
|
+
:class="
|
|
88
|
+
cn(
|
|
89
|
+
controlClass,
|
|
90
|
+
'min-h-9 gap-1 py-0.5 pr-1.5 pl-2 flex flex-wrap items-center has-[input:focus-visible]:border-xq-ring has-[input:focus-visible]:ring-[3px] has-[input:focus-visible]:ring-xq-ring/25',
|
|
91
|
+
invalid && 'border-xq-destructive',
|
|
92
|
+
props.class,
|
|
93
|
+
)
|
|
94
|
+
"
|
|
95
|
+
>
|
|
96
|
+
<template v-if="multiple">
|
|
97
|
+
<span
|
|
98
|
+
v-for="value in selectedValues"
|
|
99
|
+
:key="String(value)"
|
|
100
|
+
class="gap-1 py-0.5 pr-1 pl-2 text-xs font-medium inline-flex items-center rounded-xq-sm bg-xq-secondary text-xq-secondary-foreground"
|
|
101
|
+
>
|
|
102
|
+
{{ labelFor(value) }}
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
class="rounded-sm cursor-pointer opacity-60 hover:opacity-100"
|
|
106
|
+
:aria-label="`Remove ${labelFor(value)}`"
|
|
107
|
+
:disabled="disabled"
|
|
108
|
+
@click.stop="remove(value)"
|
|
109
|
+
>
|
|
110
|
+
<X class="size-3" />
|
|
111
|
+
</button>
|
|
112
|
+
</span>
|
|
113
|
+
</template>
|
|
114
|
+
<ComboboxInput
|
|
115
|
+
:id="id"
|
|
116
|
+
v-model="searchTerm"
|
|
117
|
+
@input="emit('search', ($event.target as HTMLInputElement).value)"
|
|
118
|
+
:display-value="multiple ? undefined : labelFor"
|
|
119
|
+
:placeholder="selectedValues.length && multiple ? '' : (placeholder ?? 'Search…')"
|
|
120
|
+
:aria-invalid="invalid || undefined"
|
|
121
|
+
:aria-describedby="describedBy"
|
|
122
|
+
class="h-7 min-w-16 px-1 text-sm flex-1 bg-transparent outline-none placeholder:text-xq-muted-foreground disabled:cursor-not-allowed"
|
|
123
|
+
/>
|
|
124
|
+
<LoaderCircle
|
|
125
|
+
v-if="loading"
|
|
126
|
+
class="size-4 motion-safe:animate-spin shrink-0 text-xq-muted-foreground"
|
|
127
|
+
/>
|
|
128
|
+
<button
|
|
129
|
+
v-else-if="selectedValues.length && !disabled"
|
|
130
|
+
type="button"
|
|
131
|
+
class="size-6 rounded-sm flex cursor-pointer items-center justify-center text-xq-muted-foreground hover:text-xq-foreground"
|
|
132
|
+
aria-label="Clear selection"
|
|
133
|
+
@click.stop="clear"
|
|
134
|
+
>
|
|
135
|
+
<X class="size-3.5" />
|
|
136
|
+
</button>
|
|
137
|
+
<ComboboxTrigger
|
|
138
|
+
class="size-6 flex cursor-pointer items-center justify-center text-xq-muted-foreground"
|
|
139
|
+
>
|
|
140
|
+
<ChevronsUpDown class="size-4" />
|
|
141
|
+
</ComboboxTrigger>
|
|
142
|
+
</ComboboxAnchor>
|
|
143
|
+
<ComboboxPortal>
|
|
144
|
+
<ComboboxContent
|
|
145
|
+
position="popper"
|
|
146
|
+
:side-offset="4"
|
|
147
|
+
:class="cn(popoverClass, 'max-h-72 min-w-40 w-(--reka-combobox-trigger-width)')"
|
|
148
|
+
>
|
|
149
|
+
<ComboboxViewport class="p-1">
|
|
150
|
+
<p
|
|
151
|
+
v-if="searchFailed"
|
|
152
|
+
role="status"
|
|
153
|
+
class="px-2 py-1.5 text-xs text-xq-destructive"
|
|
154
|
+
>
|
|
155
|
+
The search failed. Type again to retry.
|
|
156
|
+
</p>
|
|
157
|
+
<ComboboxEmpty class="px-2 py-6 text-sm text-center text-xq-muted-foreground">
|
|
158
|
+
{{
|
|
159
|
+
loading
|
|
160
|
+
? 'Searching…'
|
|
161
|
+
: searchTerm || !remote
|
|
162
|
+
? 'No results'
|
|
163
|
+
: 'Type to search'
|
|
164
|
+
}}
|
|
165
|
+
</ComboboxEmpty>
|
|
166
|
+
<ComboboxItem
|
|
167
|
+
v-for="option in options"
|
|
168
|
+
:key="String(option.value)"
|
|
169
|
+
:value="option.value"
|
|
170
|
+
:text-value="option.label"
|
|
171
|
+
:class="optionClass"
|
|
172
|
+
>
|
|
173
|
+
<span class="truncate">{{ option.label }}</span>
|
|
174
|
+
<ComboboxItemIndicator
|
|
175
|
+
class="right-2 size-3.5 absolute flex items-center justify-center"
|
|
176
|
+
>
|
|
177
|
+
<Check class="size-4" />
|
|
178
|
+
</ComboboxItemIndicator>
|
|
179
|
+
</ComboboxItem>
|
|
180
|
+
</ComboboxViewport>
|
|
181
|
+
</ComboboxContent>
|
|
182
|
+
</ComboboxPortal>
|
|
183
|
+
</ComboboxRoot>
|
|
184
|
+
</template>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from 'vue';
|
|
3
|
+
import { cn } from '../lib/utils';
|
|
4
|
+
import { controlClass } from './classes';
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{ class?: HTMLAttributes['class'] }>();
|
|
7
|
+
const model = defineModel<string | number | null>();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<input
|
|
12
|
+
v-model="model"
|
|
13
|
+
data-slot="xq-input"
|
|
14
|
+
:class="cn(controlClass, 'h-9 px-3 py-1', props.class)"
|
|
15
|
+
/>
|
|
16
|
+
</template>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Label } from 'reka-ui';
|
|
3
|
+
import type { HTMLAttributes } from 'vue';
|
|
4
|
+
import { cn } from '../lib/utils';
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{ for?: string; class?: HTMLAttributes['class'] }>();
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<Label
|
|
11
|
+
data-slot="xq-label"
|
|
12
|
+
:for="props.for"
|
|
13
|
+
:class="cn('text-sm font-medium leading-none text-xq-foreground select-none', props.class)"
|
|
14
|
+
>
|
|
15
|
+
<slot />
|
|
16
|
+
</Label>
|
|
17
|
+
</template>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { RadioGroupRoot } from 'reka-ui';
|
|
3
|
+
import type { HTMLAttributes } from 'vue';
|
|
4
|
+
import { cn } from '../lib/utils';
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
orientation?: 'horizontal' | 'vertical';
|
|
9
|
+
class?: HTMLAttributes['class'];
|
|
10
|
+
}>();
|
|
11
|
+
const model = defineModel<string | number | null>();
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<RadioGroupRoot
|
|
16
|
+
v-model="model"
|
|
17
|
+
data-slot="xq-radio-group"
|
|
18
|
+
:disabled="disabled"
|
|
19
|
+
:orientation="orientation"
|
|
20
|
+
:class="cn('gap-2.5 grid', props.class)"
|
|
21
|
+
>
|
|
22
|
+
<slot />
|
|
23
|
+
</RadioGroupRoot>
|
|
24
|
+
</template>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { RadioGroupIndicator, RadioGroupItem } from 'reka-ui';
|
|
3
|
+
import type { HTMLAttributes } from 'vue';
|
|
4
|
+
import { cn } from '../lib/utils';
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
id?: string;
|
|
8
|
+
value: string | number;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
class?: HTMLAttributes['class'];
|
|
11
|
+
}>();
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<RadioGroupItem
|
|
16
|
+
:id="id"
|
|
17
|
+
data-slot="xq-radio-group-item"
|
|
18
|
+
:value="value"
|
|
19
|
+
:disabled="disabled"
|
|
20
|
+
:class="
|
|
21
|
+
cn(
|
|
22
|
+
'size-4 shadow-xs aspect-square shrink-0 cursor-pointer rounded-full border border-xq-input bg-xq-background transition-shadow outline-none focus-visible:ring-[3px] focus-visible:ring-xq-ring/30 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-xq-primary',
|
|
23
|
+
props.class,
|
|
24
|
+
)
|
|
25
|
+
"
|
|
26
|
+
>
|
|
27
|
+
<RadioGroupIndicator class="flex items-center justify-center">
|
|
28
|
+
<span class="size-2 rounded-full bg-xq-primary" />
|
|
29
|
+
</RadioGroupIndicator>
|
|
30
|
+
</RadioGroupItem>
|
|
31
|
+
</template>
|