@xelqun/forms 0.6.0 → 0.7.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/package.json +1 -1
- package/resources/css/xelqun.css +99 -8
- package/resources/js/components/XqAction.vue +34 -9
- package/resources/js/components/XqActionContent.vue +35 -0
- package/resources/js/components/XqActionGroup.vue +15 -12
- package/resources/js/components/XqFieldWrapper.vue +7 -2
- package/resources/js/components/XqForm.vue +2 -2
- package/resources/js/components/fields/XqFileUploadField.vue +3 -3
- package/resources/js/components/fields/XqSliderField.vue +1 -1
- package/resources/js/components/fields/XqToggleButtonsField.vue +1 -1
- package/resources/js/components/layouts/XqSection.vue +11 -3
- package/resources/js/core/store.ts +4 -1
- package/resources/js/ui/XqButton.vue +1 -1
- package/resources/js/ui/XqCheckbox.vue +6 -2
- package/resources/js/ui/XqCombobox.vue +3 -1
- package/resources/js/ui/XqMenuGlide.vue +138 -0
- package/resources/js/ui/XqRadioGroupItem.vue +1 -1
- package/resources/js/ui/XqSelect.vue +2 -0
- package/resources/js/ui/XqSwitch.vue +1 -1
- package/resources/js/ui/XqTabGlide.vue +114 -0
- package/resources/js/ui/XqTabsList.vue +6 -1
- package/resources/js/ui/XqTabsTrigger.vue +1 -1
- package/resources/js/ui/classes.ts +5 -5
- package/resources/js/ui/index.ts +2 -0
package/package.json
CHANGED
package/resources/css/xelqun.css
CHANGED
|
@@ -6,12 +6,18 @@
|
|
|
6
6
|
@source "../js";
|
|
7
7
|
|
|
8
8
|
@theme inline {
|
|
9
|
-
--animate-xq-fade-in: xq-fade-in
|
|
10
|
-
--animate-xq-fade-out: xq-fade-out
|
|
11
|
-
--animate-xq-toast-in: xq-toast-in
|
|
12
|
-
--animate-xq-toast-out: xq-toast-out
|
|
13
|
-
--animate-xq-dialog-in: xq-dialog-in
|
|
14
|
-
--animate-xq-dialog-out: xq-dialog-out
|
|
9
|
+
--animate-xq-fade-in: xq-fade-in var(--xq-duration-base) var(--xq-ease-out);
|
|
10
|
+
--animate-xq-fade-out: xq-fade-out var(--xq-duration-fast) var(--xq-ease-in) forwards;
|
|
11
|
+
--animate-xq-toast-in: xq-toast-in var(--xq-duration-slow) var(--xq-ease-out);
|
|
12
|
+
--animate-xq-toast-out: xq-toast-out var(--xq-duration-base) var(--xq-ease-in) forwards;
|
|
13
|
+
--animate-xq-dialog-in: xq-dialog-in var(--xq-duration-slow) var(--xq-ease-out);
|
|
14
|
+
--animate-xq-dialog-out: xq-dialog-out var(--xq-duration-fast) var(--xq-ease-in) forwards;
|
|
15
|
+
--animate-xq-pop-in: xq-pop-in var(--xq-duration-base) var(--xq-ease-out);
|
|
16
|
+
--animate-xq-pop-out: xq-pop-out var(--xq-duration-fast) var(--xq-ease-in) forwards;
|
|
17
|
+
--animate-xq-rise-in: xq-rise-in var(--xq-duration-slow) var(--xq-ease-out) both;
|
|
18
|
+
--animate-xq-error-in: xq-error-in var(--xq-duration-base) var(--xq-ease-out);
|
|
19
|
+
--animate-xq-shake: xq-shake var(--xq-duration-slow) var(--xq-ease-out);
|
|
20
|
+
--animate-xq-check-draw: xq-check-draw var(--xq-duration-base) var(--xq-ease-out) both;
|
|
15
21
|
--color-xq-background: var(--xq-background);
|
|
16
22
|
--color-xq-foreground: var(--xq-foreground);
|
|
17
23
|
--color-xq-card: var(--xq-card);
|
|
@@ -288,6 +294,31 @@
|
|
|
288
294
|
height: auto;
|
|
289
295
|
}
|
|
290
296
|
|
|
297
|
+
/*
|
|
298
|
+
* Motion, in one place. Everything that moves reads these, across forms, tables
|
|
299
|
+
* and panels, so a calmer app changes four lines. Things leave faster than they
|
|
300
|
+
* arrive, and only opacity, transform and filter animate, so nothing reflows.
|
|
301
|
+
*/
|
|
302
|
+
:root {
|
|
303
|
+
--xq-duration-fast: 120ms;
|
|
304
|
+
--xq-duration-base: 200ms;
|
|
305
|
+
--xq-duration-slow: 320ms;
|
|
306
|
+
--xq-stagger: 45ms;
|
|
307
|
+
--xq-ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
|
308
|
+
--xq-ease-in: cubic-bezier(0.4, 0, 1, 1);
|
|
309
|
+
--xq-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/* One guard for all of it. Not zero, so the events that close a dialog still fire. */
|
|
313
|
+
@media (prefers-reduced-motion: reduce) {
|
|
314
|
+
:root {
|
|
315
|
+
--xq-duration-fast: 1ms;
|
|
316
|
+
--xq-duration-base: 1ms;
|
|
317
|
+
--xq-duration-slow: 1ms;
|
|
318
|
+
--xq-stagger: 0ms;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
291
322
|
@keyframes xq-fade-in {
|
|
292
323
|
from {
|
|
293
324
|
opacity: 0;
|
|
@@ -303,7 +334,9 @@
|
|
|
303
334
|
@keyframes xq-toast-in {
|
|
304
335
|
from {
|
|
305
336
|
opacity: 0;
|
|
306
|
-
translate: 0 -0.
|
|
337
|
+
translate: 0 -0.75rem;
|
|
338
|
+
scale: 0.96;
|
|
339
|
+
filter: blur(4px);
|
|
307
340
|
}
|
|
308
341
|
}
|
|
309
342
|
|
|
@@ -311,19 +344,77 @@
|
|
|
311
344
|
to {
|
|
312
345
|
opacity: 0;
|
|
313
346
|
translate: 1rem 0;
|
|
347
|
+
filter: blur(2px);
|
|
314
348
|
}
|
|
315
349
|
}
|
|
316
350
|
|
|
317
351
|
@keyframes xq-dialog-in {
|
|
318
352
|
from {
|
|
319
353
|
opacity: 0;
|
|
320
|
-
scale: 0.
|
|
354
|
+
scale: 0.94;
|
|
355
|
+
filter: blur(6px);
|
|
321
356
|
}
|
|
322
357
|
}
|
|
323
358
|
|
|
324
359
|
@keyframes xq-dialog-out {
|
|
360
|
+
to {
|
|
361
|
+
opacity: 0;
|
|
362
|
+
scale: 0.97;
|
|
363
|
+
filter: blur(2px);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/* Grows from whatever opened it, as long as the element sets its transform-origin. */
|
|
368
|
+
@keyframes xq-pop-in {
|
|
369
|
+
from {
|
|
370
|
+
opacity: 0;
|
|
371
|
+
scale: 0.95;
|
|
372
|
+
filter: blur(3px);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
@keyframes xq-pop-out {
|
|
325
377
|
to {
|
|
326
378
|
opacity: 0;
|
|
327
379
|
scale: 0.98;
|
|
328
380
|
}
|
|
329
381
|
}
|
|
382
|
+
|
|
383
|
+
@keyframes xq-rise-in {
|
|
384
|
+
from {
|
|
385
|
+
opacity: 0;
|
|
386
|
+
translate: 0 0.5rem;
|
|
387
|
+
filter: blur(3px);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
@keyframes xq-error-in {
|
|
392
|
+
from {
|
|
393
|
+
opacity: 0;
|
|
394
|
+
translate: 0 -0.25rem;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
@keyframes xq-shake {
|
|
399
|
+
20% {
|
|
400
|
+
translate: -5px 0;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
45% {
|
|
404
|
+
translate: 4px 0;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
70% {
|
|
408
|
+
translate: -2px 0;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
@keyframes xq-check-draw {
|
|
413
|
+
from {
|
|
414
|
+
stroke-dashoffset: 24;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
to {
|
|
418
|
+
stroke-dashoffset: 0;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
@@ -5,6 +5,9 @@ import type { FormStore } from '../core/store';
|
|
|
5
5
|
* What the button needs from whatever holds it. Outside a form, pass your own.
|
|
6
6
|
*/
|
|
7
7
|
export type ActionRunner = Pick<FormStore, 'mountAction' | 'runAction' | 'processing' | 't'>;
|
|
8
|
+
|
|
9
|
+
/** How the last run went, for as long as the button shows it. */
|
|
10
|
+
export type ActionOutcome = 'done' | 'failed' | null;
|
|
8
11
|
</script>
|
|
9
12
|
|
|
10
13
|
<script setup lang="ts">
|
|
@@ -21,6 +24,7 @@ import XqConfirmDialog from '../ui/XqConfirmDialog.vue';
|
|
|
21
24
|
import XqDialog from '../ui/XqDialog.vue';
|
|
22
25
|
import XqDialogFooter from '../ui/XqDialogFooter.vue';
|
|
23
26
|
import XqIcon from '../ui/XqIcon.vue';
|
|
27
|
+
import XqActionContent from './XqActionContent.vue';
|
|
24
28
|
import XqForm from './XqForm.vue';
|
|
25
29
|
|
|
26
30
|
const props = defineProps<{
|
|
@@ -29,7 +33,7 @@ const props = defineProps<{
|
|
|
29
33
|
/** Keep the dialogs and leave the button out, for a menu that has its own. */
|
|
30
34
|
headless?: boolean;
|
|
31
35
|
}>();
|
|
32
|
-
const emit = defineEmits<{ spinner: [showsSpinner: boolean] }>();
|
|
36
|
+
const emit = defineEmits<{ spinner: [showsSpinner: boolean]; outcome: [outcome: ActionOutcome] }>();
|
|
33
37
|
const store = props.runner ?? useFormStore();
|
|
34
38
|
const options = computed(() => props.node.props ?? {});
|
|
35
39
|
const running = ref(false);
|
|
@@ -37,6 +41,7 @@ const showsSpinner = ref(false);
|
|
|
37
41
|
const confirming = ref(false);
|
|
38
42
|
const modalOpen = ref(false);
|
|
39
43
|
const modal = shallowRef<FormStore | null>(null);
|
|
44
|
+
const outcome = ref<ActionOutcome>(null);
|
|
40
45
|
|
|
41
46
|
const variant = computed(() =>
|
|
42
47
|
options.value.style === 'link'
|
|
@@ -49,7 +54,21 @@ const variant = computed(() =>
|
|
|
49
54
|
// A new tab isn't a visit.
|
|
50
55
|
const isVisit = computed(() => !options.value.openInNewTab && isOwnUrl(options.value.url));
|
|
51
56
|
|
|
57
|
+
/** Long enough to register, and short enough to be ready for another click. */
|
|
58
|
+
const OUTCOME_DURATION = 1000;
|
|
59
|
+
|
|
52
60
|
let spinnerTimer: ReturnType<typeof setTimeout> | undefined;
|
|
61
|
+
let outcomeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
62
|
+
|
|
63
|
+
function showOutcome(next: ActionOutcome): void {
|
|
64
|
+
clearTimeout(outcomeTimer);
|
|
65
|
+
outcome.value = next;
|
|
66
|
+
emit('outcome', next);
|
|
67
|
+
outcomeTimer = setTimeout(() => {
|
|
68
|
+
outcome.value = null;
|
|
69
|
+
emit('outcome', null);
|
|
70
|
+
}, OUTCOME_DURATION);
|
|
71
|
+
}
|
|
53
72
|
|
|
54
73
|
// A quick action changes nothing on screen until it's done, so it can't flicker.
|
|
55
74
|
watch(running, (isRunning) => {
|
|
@@ -71,6 +90,11 @@ async function run(): Promise<void> {
|
|
|
71
90
|
|
|
72
91
|
const result = await store.runAction(props.node, modal.value ?? undefined);
|
|
73
92
|
|
|
93
|
+
// A modal keeps its failure to itself, on the field or message that explains it.
|
|
94
|
+
if (result || !modalOpen.value) {
|
|
95
|
+
showOutcome(result ? 'done' : 'failed');
|
|
96
|
+
}
|
|
97
|
+
|
|
74
98
|
if (result) {
|
|
75
99
|
modalOpen.value = false;
|
|
76
100
|
}
|
|
@@ -131,6 +155,7 @@ defineExpose({ open });
|
|
|
131
155
|
|
|
132
156
|
onScopeDispose(() => {
|
|
133
157
|
clearTimeout(spinnerTimer);
|
|
158
|
+
clearTimeout(outcomeTimer);
|
|
134
159
|
closeModal();
|
|
135
160
|
});
|
|
136
161
|
</script>
|
|
@@ -161,22 +186,22 @@ onScopeDispose(() => {
|
|
|
161
186
|
type="button"
|
|
162
187
|
:variant="variant"
|
|
163
188
|
:size="options.style === 'icon' ? 'icon-sm' : 'sm'"
|
|
164
|
-
:class="
|
|
189
|
+
:class="
|
|
190
|
+
cn(
|
|
191
|
+
showsSpinner && 'pointer-events-none',
|
|
192
|
+
outcome === 'failed' && 'animate-xq-shake',
|
|
193
|
+
)
|
|
194
|
+
"
|
|
165
195
|
:disabled="!!options.disabled || store.processing.value"
|
|
166
196
|
:title="options.tooltip"
|
|
167
197
|
:aria-label="options.style === 'icon' ? options.label : undefined"
|
|
168
198
|
:aria-busy="running || undefined"
|
|
169
199
|
@click="open"
|
|
170
200
|
>
|
|
171
|
-
<
|
|
201
|
+
<XqActionContent :spinning="showsSpinner" :done="outcome === 'done'">
|
|
172
202
|
<XqIcon v-if="options.icon" :name="options.icon" />
|
|
173
203
|
<template v-if="options.style !== 'icon'">{{ options.label }}</template>
|
|
174
|
-
</
|
|
175
|
-
<LoaderCircle
|
|
176
|
-
v-if="showsSpinner"
|
|
177
|
-
class="motion-safe:animate-spin absolute"
|
|
178
|
-
aria-hidden="true"
|
|
179
|
-
/>
|
|
204
|
+
</XqActionContent>
|
|
180
205
|
</XqButton>
|
|
181
206
|
</template>
|
|
182
207
|
<XqConfirmDialog
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Check, LoaderCircle } from 'lucide-vue-next';
|
|
3
|
+
import { computed } from 'vue';
|
|
4
|
+
import { cn } from '../lib/utils';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What's on a button that runs something: its label, then a spinner if it takes a
|
|
8
|
+
* while, then a check that draws on when it's done. All three share one spot, so
|
|
9
|
+
* the button never changes size.
|
|
10
|
+
*/
|
|
11
|
+
const props = defineProps<{ spinning?: boolean; done?: boolean }>();
|
|
12
|
+
|
|
13
|
+
const isHidden = computed(() => props.spinning || props.done);
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<template>
|
|
17
|
+
<span class="grid place-items-center">
|
|
18
|
+
<span
|
|
19
|
+
:class="
|
|
20
|
+
cn(
|
|
21
|
+
'gap-1.5 inline-flex items-center transition-[opacity,scale] duration-(--xq-duration-fast) [grid-area:1/1]',
|
|
22
|
+
isHidden && 'scale-90 opacity-0',
|
|
23
|
+
)
|
|
24
|
+
"
|
|
25
|
+
>
|
|
26
|
+
<slot />
|
|
27
|
+
</span>
|
|
28
|
+
<span v-if="spinning" class="animate-xq-pop-in [grid-area:1/1]" aria-hidden="true">
|
|
29
|
+
<LoaderCircle class="motion-safe:animate-spin" />
|
|
30
|
+
</span>
|
|
31
|
+
<span v-else-if="done" class="[grid-area:1/1]" aria-hidden="true" data-xq-action-done>
|
|
32
|
+
<Check class="[&_path]:animate-xq-check-draw [&_path]:[stroke-dasharray:24]" />
|
|
33
|
+
</span>
|
|
34
|
+
</span>
|
|
35
|
+
</template>
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { Link } from '@inertiajs/vue3';
|
|
3
|
-
import { LoaderCircle } from 'lucide-vue-next';
|
|
4
3
|
import {
|
|
5
4
|
DropdownMenuContent,
|
|
6
5
|
DropdownMenuItem,
|
|
@@ -16,7 +15,10 @@ import type { SchemaNode } from '../schema/types';
|
|
|
16
15
|
import { actionVariants, optionClass, popoverClass } from '../ui/classes';
|
|
17
16
|
import XqButton from '../ui/XqButton.vue';
|
|
18
17
|
import XqIcon from '../ui/XqIcon.vue';
|
|
18
|
+
import XqMenuGlide from '../ui/XqMenuGlide.vue';
|
|
19
19
|
import XqAction from './XqAction.vue';
|
|
20
|
+
import type { ActionOutcome } from './XqAction.vue';
|
|
21
|
+
import XqActionContent from './XqActionContent.vue';
|
|
20
22
|
import type { ActionRunner } from './XqAction.vue';
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -33,6 +35,7 @@ const items = computed(() =>
|
|
|
33
35
|
const headlessActions = computed(() => items.value.filter((item) => !item.props?.url));
|
|
34
36
|
const hasLabel = computed(() => !!options.value.label);
|
|
35
37
|
const showsSpinner = ref(false);
|
|
38
|
+
const outcome = ref<ActionOutcome>(null);
|
|
36
39
|
const actions = new Map<string, { open: () => Promise<void> }>();
|
|
37
40
|
let selected: string | null = null;
|
|
38
41
|
|
|
@@ -64,32 +67,31 @@ function openSelected(): void {
|
|
|
64
67
|
type="button"
|
|
65
68
|
:variant="hasLabel ? (actionVariants[options.color] ?? 'outline') : 'ghost'"
|
|
66
69
|
:size="hasLabel ? 'sm' : 'icon-sm'"
|
|
67
|
-
:class="
|
|
70
|
+
:class="
|
|
71
|
+
cn(
|
|
72
|
+
showsSpinner && 'pointer-events-none',
|
|
73
|
+
outcome === 'failed' && 'animate-xq-shake',
|
|
74
|
+
)
|
|
75
|
+
"
|
|
68
76
|
:disabled="store.processing.value"
|
|
69
77
|
:title="options.tooltip"
|
|
70
78
|
:aria-label="hasLabel ? undefined : store.t('Actions')"
|
|
71
79
|
:aria-busy="showsSpinner || undefined"
|
|
72
80
|
>
|
|
73
|
-
<
|
|
74
|
-
:class="cn('gap-1.5 inline-flex items-center', showsSpinner && 'invisible')"
|
|
75
|
-
>
|
|
81
|
+
<XqActionContent :spinning="showsSpinner" :done="outcome === 'done'">
|
|
76
82
|
<XqIcon :name="options.icon ?? 'ellipsis-vertical'" />
|
|
77
83
|
<template v-if="hasLabel">{{ options.label }}</template>
|
|
78
|
-
</
|
|
79
|
-
<LoaderCircle
|
|
80
|
-
v-if="showsSpinner"
|
|
81
|
-
class="motion-safe:animate-spin absolute"
|
|
82
|
-
aria-hidden="true"
|
|
83
|
-
/>
|
|
84
|
+
</XqActionContent>
|
|
84
85
|
</XqButton>
|
|
85
86
|
</DropdownMenuTrigger>
|
|
86
87
|
<DropdownMenuPortal>
|
|
87
88
|
<DropdownMenuContent
|
|
88
89
|
align="end"
|
|
89
90
|
:side-offset="4"
|
|
90
|
-
:class="cn(popoverClass, 'p-1 min-w-40')"
|
|
91
|
+
:class="cn(popoverClass, 'p-1 min-w-40 relative')"
|
|
91
92
|
@close-auto-focus="openSelected"
|
|
92
93
|
>
|
|
94
|
+
<XqMenuGlide />
|
|
93
95
|
<template v-for="item in items" :key="item.key">
|
|
94
96
|
<DropdownMenuItem
|
|
95
97
|
v-if="item.props?.url"
|
|
@@ -139,6 +141,7 @@ function openSelected(): void {
|
|
|
139
141
|
:runner="store"
|
|
140
142
|
headless
|
|
141
143
|
@spinner="showsSpinner = $event"
|
|
144
|
+
@outcome="outcome = $event"
|
|
142
145
|
/>
|
|
143
146
|
</div>
|
|
144
147
|
</div>
|
|
@@ -44,7 +44,12 @@ const hasContentSlots = computed(() =>
|
|
|
44
44
|
|
|
45
45
|
<template>
|
|
46
46
|
<div
|
|
47
|
-
:class="
|
|
47
|
+
:class="
|
|
48
|
+
cn(
|
|
49
|
+
'gap-2 grid data-invalid:animate-xq-shake',
|
|
50
|
+
inlineLabel && 'sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]',
|
|
51
|
+
)
|
|
52
|
+
"
|
|
48
53
|
:data-xq-path="node.path"
|
|
49
54
|
:data-invalid="error ? '' : undefined"
|
|
50
55
|
:aria-busy="loading || undefined"
|
|
@@ -93,7 +98,7 @@ const hasContentSlots = computed(() =>
|
|
|
93
98
|
v-if="error && !fused"
|
|
94
99
|
:id="`${id}-error`"
|
|
95
100
|
:role="announceError ? 'alert' : undefined"
|
|
96
|
-
class="text-xs font-medium text-xq-destructive"
|
|
101
|
+
class="text-xs font-medium animate-xq-error-in text-xq-destructive"
|
|
97
102
|
>
|
|
98
103
|
{{ error }}
|
|
99
104
|
</p>
|
|
@@ -5,7 +5,7 @@ import { FUSED_GROUP, HEADING_LEVEL, provideFormStore } from '../composables/con
|
|
|
5
5
|
import { REGISTRY } from '../composables/registry';
|
|
6
6
|
import { useUnsavedChangesAlert } from '../composables/useUnsavedChangesAlert';
|
|
7
7
|
import { useXqForm } from '../composables/useXqForm';
|
|
8
|
-
import type { FormStore } from '../core/store';
|
|
8
|
+
import type { FormStore, SubmitOptions } from '../core/store';
|
|
9
9
|
import { cn } from '../lib/utils';
|
|
10
10
|
import type { FormSchema } from '../schema/types';
|
|
11
11
|
import XqButton from '../ui/XqButton.vue';
|
|
@@ -18,7 +18,7 @@ const props = withDefaults(
|
|
|
18
18
|
action?: string;
|
|
19
19
|
method?: 'post' | 'put' | 'patch';
|
|
20
20
|
submitLabel?: string;
|
|
21
|
-
preserveState?:
|
|
21
|
+
preserveState?: SubmitOptions['preserveState'];
|
|
22
22
|
/** Heading level of top-level section titles. Set it to fit the page around the form. */
|
|
23
23
|
headingLevel?: 2 | 3 | 4 | 5 | 6;
|
|
24
24
|
class?: string;
|
|
@@ -416,7 +416,7 @@ onBeforeUnmount(() => {
|
|
|
416
416
|
v-for="(file, index) in files"
|
|
417
417
|
:key="file.value"
|
|
418
418
|
:data-xq-file="file.value"
|
|
419
|
-
class="gap-3 p-2 pr-3 flex items-center rounded-xq-md border border-xq-border bg-xq-
|
|
419
|
+
class="gap-3 p-2 pr-3 flex items-center rounded-xq-md border border-xq-border bg-xq-card"
|
|
420
420
|
>
|
|
421
421
|
<span
|
|
422
422
|
v-if="canReorder && files.length > 1"
|
|
@@ -521,7 +521,7 @@ onBeforeUnmount(() => {
|
|
|
521
521
|
<div
|
|
522
522
|
v-for="item in pending"
|
|
523
523
|
:key="item.id"
|
|
524
|
-
class="gap-3 p-2 pr-3 flex items-center rounded-xq-md border border-dashed border-xq-border bg-xq-
|
|
524
|
+
class="gap-3 p-2 pr-3 flex items-center rounded-xq-md border border-dashed border-xq-border bg-xq-card"
|
|
525
525
|
>
|
|
526
526
|
<LoaderCircle class="size-5 motion-safe:animate-spin shrink-0 text-xq-primary" />
|
|
527
527
|
<div class="min-w-0 gap-1 grid flex-1">
|
|
@@ -555,7 +555,7 @@ onBeforeUnmount(() => {
|
|
|
555
555
|
:for="id"
|
|
556
556
|
:class="
|
|
557
557
|
cn(
|
|
558
|
-
'gap-1.5 px-4 py-5 flex cursor-pointer flex-col items-center justify-center border border-dashed border-xq-input bg-xq-
|
|
558
|
+
'gap-1.5 px-4 py-5 flex cursor-pointer flex-col items-center justify-center border border-dashed border-xq-input bg-xq-card text-center transition-[color,background-color,border-color,box-shadow] outline-none hover:bg-xq-muted/50 has-[input:focus-visible]:border-xq-ring has-[input:focus-visible]:ring-[3px] has-[input:focus-visible]:ring-xq-ring/25',
|
|
559
559
|
isAvatar ? 'size-24 rounded-full' : 'rounded-xq-md',
|
|
560
560
|
dragging && 'border-xq-primary bg-xq-primary/5',
|
|
561
561
|
(error || uploadError) && 'border-xq-destructive',
|
|
@@ -36,7 +36,7 @@ const handles = computed<number[]>({
|
|
|
36
36
|
v-for="(_, index) in handles"
|
|
37
37
|
:key="index"
|
|
38
38
|
:aria-label="label ?? undefined"
|
|
39
|
-
class="size-4 shadow-xs block cursor-pointer rounded-full border border-xq-primary bg-xq-
|
|
39
|
+
class="size-4 shadow-xs block cursor-pointer rounded-full border border-xq-primary bg-xq-card outline-none focus-visible:ring-[3px] focus-visible:ring-xq-ring/30"
|
|
40
40
|
/>
|
|
41
41
|
</SliderRoot>
|
|
42
42
|
<output v-if="options.tooltips" class="text-sm text-right tabular-nums">
|
|
@@ -59,7 +59,7 @@ const model = computed<any>({
|
|
|
59
59
|
:disabled="option.disabled"
|
|
60
60
|
:class="
|
|
61
61
|
cn(
|
|
62
|
-
'gap-2 h-9 px-3 text-sm font-medium inline-flex cursor-pointer items-center justify-center border border-xq-input bg-xq-
|
|
62
|
+
'gap-2 h-9 px-3 text-sm font-medium inline-flex cursor-pointer items-center justify-center border border-xq-input bg-xq-card text-xq-foreground transition-colors outline-none hover:bg-xq-accent focus-visible:ring-[3px] focus-visible:ring-xq-ring/25 disabled:cursor-not-allowed disabled:opacity-50',
|
|
63
63
|
isGrouped
|
|
64
64
|
? 'first:ml-0 -ml-px first:rounded-l-xq-md last:rounded-r-xq-md'
|
|
65
65
|
: 'rounded-xq-md',
|
|
@@ -50,15 +50,23 @@ provide(HEADING_LEVEL, Math.min(headingLevel + 1, 6));
|
|
|
50
50
|
<ChevronDown
|
|
51
51
|
:class="
|
|
52
52
|
cn(
|
|
53
|
-
'size-4 transition-transform
|
|
53
|
+
'size-4 transition-transform duration-(--xq-duration-slow) ease-(--xq-ease-out)',
|
|
54
54
|
collapsed && '-rotate-90',
|
|
55
55
|
)
|
|
56
56
|
"
|
|
57
57
|
/>
|
|
58
58
|
</button>
|
|
59
59
|
</header>
|
|
60
|
-
<div
|
|
61
|
-
|
|
60
|
+
<div
|
|
61
|
+
class="grid transition-[grid-template-rows] duration-(--xq-duration-slow) ease-(--xq-ease-out)"
|
|
62
|
+
:class="collapsed ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]'"
|
|
63
|
+
:inert="collapsed"
|
|
64
|
+
>
|
|
65
|
+
<div class="min-h-0 overflow-hidden">
|
|
66
|
+
<div class="xq-grid p-5" :style="gridStyle(options.columns)">
|
|
67
|
+
<XqNode v-for="child in node.children ?? []" :key="child.key" :node="child" />
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
62
70
|
</div>
|
|
63
71
|
</section>
|
|
64
72
|
</template>
|
|
@@ -42,7 +42,10 @@ export const LOADING_INDICATOR_DELAY = 200;
|
|
|
42
42
|
export interface SubmitOptions {
|
|
43
43
|
method?: 'post' | 'put' | 'patch';
|
|
44
44
|
data?: Record<string, unknown>;
|
|
45
|
-
preserveState?:
|
|
45
|
+
preserveState?:
|
|
46
|
+
| boolean
|
|
47
|
+
| 'errors'
|
|
48
|
+
| ((page: { url: string; props: Record<string, unknown> }) => boolean);
|
|
46
49
|
onSuccess?: (page: unknown) => void;
|
|
47
50
|
onError?: (errors: Record<string, string>) => void;
|
|
48
51
|
onFinish?: () => void;
|
|
@@ -7,7 +7,7 @@ import type { HTMLAttributes } from 'vue';
|
|
|
7
7
|
import { cn } from '../lib/utils';
|
|
8
8
|
|
|
9
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',
|
|
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,scale] duration-(--xq-duration-fast) outline-none focus-visible:ring-[3px] focus-visible:ring-xq-ring/40 not-disabled:active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:transition-transform [&_svg]:duration-(--xq-duration-base) [&_svg]:ease-(--xq-ease-spring) not-disabled:hover:[&_svg]:scale-110',
|
|
11
11
|
{
|
|
12
12
|
variants: {
|
|
13
13
|
variant: {
|
|
@@ -22,14 +22,18 @@ const model = defineModel<boolean>({ default: false });
|
|
|
22
22
|
@update:model-value="model = $event === true"
|
|
23
23
|
:class="
|
|
24
24
|
cn(
|
|
25
|
-
'peer size-4 shadow-xs shrink-0 cursor-pointer rounded-[4px] border border-xq-input bg-xq-
|
|
25
|
+
'peer size-4 shadow-xs shrink-0 cursor-pointer rounded-[4px] border border-xq-input bg-xq-card 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 data-[state=indeterminate]:border-xq-primary data-[state=indeterminate]:bg-xq-primary data-[state=indeterminate]:text-xq-primary-foreground',
|
|
26
26
|
props.class,
|
|
27
27
|
)
|
|
28
28
|
"
|
|
29
29
|
>
|
|
30
30
|
<CheckboxIndicator class="flex items-center justify-center text-current">
|
|
31
31
|
<Minus v-if="indeterminate" class="size-3.5" :stroke-width="3" />
|
|
32
|
-
<Check
|
|
32
|
+
<Check
|
|
33
|
+
v-else
|
|
34
|
+
class="size-3.5 [&_path]:animate-xq-check-draw [&_path]:[stroke-dasharray:24]"
|
|
35
|
+
:stroke-width="3"
|
|
36
|
+
/>
|
|
33
37
|
</CheckboxIndicator>
|
|
34
38
|
</CheckboxRoot>
|
|
35
39
|
</template>
|
|
@@ -20,6 +20,7 @@ import { useSortable } from '../composables/useSortable';
|
|
|
20
20
|
import { cn } from '../lib/utils';
|
|
21
21
|
import type { SelectMessages, SelectOption } from '../schema/types';
|
|
22
22
|
import { controlClass, groupLabelClass, optionClass, popoverClass, spinnerClass } from './classes';
|
|
23
|
+
import XqMenuGlide from './XqMenuGlide.vue';
|
|
23
24
|
import { groupOptions, moveItem } from './options';
|
|
24
25
|
import { useTranslator } from '../composables/context';
|
|
25
26
|
|
|
@@ -193,7 +194,8 @@ function clear(): void {
|
|
|
193
194
|
:side-offset="4"
|
|
194
195
|
:class="cn(popoverClass, 'max-h-72 min-w-40 w-(--reka-combobox-trigger-width)')"
|
|
195
196
|
>
|
|
196
|
-
<ComboboxViewport class="p-1">
|
|
197
|
+
<ComboboxViewport class="p-1 relative">
|
|
198
|
+
<XqMenuGlide />
|
|
197
199
|
<p
|
|
198
200
|
v-if="searchFailed"
|
|
199
201
|
role="status"
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
|
3
|
+
import type { HTMLAttributes } from 'vue';
|
|
4
|
+
import { cn } from '../lib/utils';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One highlight that slides between a list's items, passing over the ones
|
|
8
|
+
* between. Put it first inside a `relative` list. It follows `data-highlighted`,
|
|
9
|
+
* so the arrow keys move it the same as the pointer. A list without a highlight
|
|
10
|
+
* of its own, such as a grid of cards, passes `hover-items` instead.
|
|
11
|
+
*/
|
|
12
|
+
const props = defineProps<{ hoverItems?: string; class?: HTMLAttributes['class'] }>();
|
|
13
|
+
|
|
14
|
+
const element = ref<HTMLElement>();
|
|
15
|
+
const box = ref({ top: 0, left: 0, right: 0, height: 0 });
|
|
16
|
+
const isVisible = ref(false);
|
|
17
|
+
const isGliding = ref(false);
|
|
18
|
+
|
|
19
|
+
const HIDE_DELAY = 60;
|
|
20
|
+
|
|
21
|
+
let observer: MutationObserver | undefined;
|
|
22
|
+
let hideTimer: ReturnType<typeof setTimeout> | undefined;
|
|
23
|
+
let hoveredItem: HTMLElement | undefined;
|
|
24
|
+
|
|
25
|
+
function follow(): void {
|
|
26
|
+
const list = element.value?.parentElement;
|
|
27
|
+
const item = hoveredItem ?? list?.querySelector<HTMLElement>('[data-highlighted]');
|
|
28
|
+
|
|
29
|
+
clearTimeout(hideTimer);
|
|
30
|
+
|
|
31
|
+
if (!list || !item) {
|
|
32
|
+
// Items drop the highlight for a moment as the pointer crosses between them.
|
|
33
|
+
hideTimer = setTimeout(() => {
|
|
34
|
+
isVisible.value = false;
|
|
35
|
+
isGliding.value = false;
|
|
36
|
+
}, HIDE_DELAY);
|
|
37
|
+
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Layout offsets, because a menu is scaled while it opens and on-screen sizes would be off.
|
|
42
|
+
// Walked up to the list, because an item's wrapper can be animating and so count as its parent.
|
|
43
|
+
let top = 0;
|
|
44
|
+
let left = 0;
|
|
45
|
+
|
|
46
|
+
for (let node: HTMLElement | null = item; node && node !== list;) {
|
|
47
|
+
top += node.offsetTop;
|
|
48
|
+
left += node.offsetLeft;
|
|
49
|
+
node = node.offsetParent as HTMLElement | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
isGliding.value = isVisible.value;
|
|
53
|
+
isVisible.value = true;
|
|
54
|
+
// Edges in place of a width, because a list can settle its width after the first highlight.
|
|
55
|
+
box.value = {
|
|
56
|
+
top,
|
|
57
|
+
left,
|
|
58
|
+
right: list.clientWidth - left - item.offsetWidth,
|
|
59
|
+
height: item.offsetHeight,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function hoverItem(event: Event): void {
|
|
64
|
+
const target = event.target as HTMLElement;
|
|
65
|
+
|
|
66
|
+
// Crossing the gap between two cards shouldn't drop the highlight.
|
|
67
|
+
if (target !== event.currentTarget) {
|
|
68
|
+
hoveredItem = target.closest<HTMLElement>(props.hoverItems!) ?? undefined;
|
|
69
|
+
follow();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function leaveItems(): void {
|
|
74
|
+
hoveredItem = undefined;
|
|
75
|
+
follow();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
onMounted(() => {
|
|
79
|
+
const list = element.value?.parentElement;
|
|
80
|
+
|
|
81
|
+
if (!list) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (props.hoverItems) {
|
|
86
|
+
list.addEventListener('pointerover', hoverItem);
|
|
87
|
+
list.addEventListener('pointerleave', leaveItems);
|
|
88
|
+
list.addEventListener('focusin', hoverItem);
|
|
89
|
+
list.addEventListener('focusout', leaveItems);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
observer = new MutationObserver(follow);
|
|
93
|
+
observer.observe(list, {
|
|
94
|
+
subtree: true,
|
|
95
|
+
childList: true,
|
|
96
|
+
attributes: true,
|
|
97
|
+
attributeFilter: ['data-highlighted'],
|
|
98
|
+
});
|
|
99
|
+
follow();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
onBeforeUnmount(() => {
|
|
103
|
+
const list = element.value?.parentElement;
|
|
104
|
+
|
|
105
|
+
list?.removeEventListener('pointerover', hoverItem);
|
|
106
|
+
list?.removeEventListener('pointerleave', leaveItems);
|
|
107
|
+
list?.removeEventListener('focusin', hoverItem);
|
|
108
|
+
list?.removeEventListener('focusout', leaveItems);
|
|
109
|
+
observer?.disconnect();
|
|
110
|
+
clearTimeout(hideTimer);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const style = computed(() => ({
|
|
114
|
+
transform: `translateY(${box.value.top}px)`,
|
|
115
|
+
left: `${box.value.left}px`,
|
|
116
|
+
right: `${box.value.right}px`,
|
|
117
|
+
height: `${box.value.height}px`,
|
|
118
|
+
opacity: isVisible.value ? 1 : 0,
|
|
119
|
+
}));
|
|
120
|
+
</script>
|
|
121
|
+
|
|
122
|
+
<template>
|
|
123
|
+
<span
|
|
124
|
+
ref="element"
|
|
125
|
+
aria-hidden="true"
|
|
126
|
+
:class="
|
|
127
|
+
cn(
|
|
128
|
+
'top-0 pointer-events-none absolute',
|
|
129
|
+
isGliding
|
|
130
|
+
? 'transition-[transform,left,right,height,opacity] duration-(--xq-duration-base) ease-(--xq-ease-out)'
|
|
131
|
+
: 'transition-opacity duration-(--xq-duration-fast)',
|
|
132
|
+
props.class || 'rounded-xq-sm bg-xq-accent',
|
|
133
|
+
)
|
|
134
|
+
"
|
|
135
|
+
:style="style"
|
|
136
|
+
data-xq-menu-glide
|
|
137
|
+
></span>
|
|
138
|
+
</template>
|
|
@@ -19,7 +19,7 @@ const props = defineProps<{
|
|
|
19
19
|
:disabled="disabled"
|
|
20
20
|
:class="
|
|
21
21
|
cn(
|
|
22
|
-
'size-4 shadow-xs aspect-square shrink-0 cursor-pointer rounded-full border border-xq-input bg-xq-
|
|
22
|
+
'size-4 shadow-xs aspect-square shrink-0 cursor-pointer rounded-full border border-xq-input bg-xq-card 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
23
|
props.class,
|
|
24
24
|
)
|
|
25
25
|
"
|
|
@@ -18,6 +18,7 @@ import type { HTMLAttributes } from 'vue';
|
|
|
18
18
|
import { cn } from '../lib/utils';
|
|
19
19
|
import type { SelectMessages, SelectOption } from '../schema/types';
|
|
20
20
|
import { controlClass, groupLabelClass, optionClass, popoverClass, spinnerClass } from './classes';
|
|
21
|
+
import XqMenuGlide from './XqMenuGlide.vue';
|
|
21
22
|
import { groupOptions } from './options';
|
|
22
23
|
import { useTranslator } from '../composables/context';
|
|
23
24
|
|
|
@@ -63,6 +64,7 @@ const groups = computed(() => groupOptions(props.options));
|
|
|
63
64
|
:class="cn(popoverClass, 'max-h-72 min-w-32 w-(--reka-select-trigger-width)')"
|
|
64
65
|
>
|
|
65
66
|
<SelectViewport class="p-1">
|
|
67
|
+
<XqMenuGlide />
|
|
66
68
|
<SelectGroup v-for="group in groups" :key="group.label ?? ''">
|
|
67
69
|
<SelectLabel v-if="group.label" :class="groupLabelClass">{{
|
|
68
70
|
group.label
|
|
@@ -21,7 +21,7 @@ const model = defineModel<boolean>({ default: false });
|
|
|
21
21
|
"
|
|
22
22
|
>
|
|
23
23
|
<SwitchThumb
|
|
24
|
-
class="size-4 shadow-sm data-[state=unchecked]:translate-x-0.5 pointer-events-none block rounded-full bg-xq-background ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-1px)] motion-reduce:transition-none"
|
|
24
|
+
class="size-4 shadow-sm data-[state=unchecked]:translate-x-0.5 pointer-events-none block rounded-full bg-xq-background ring-0 transition-transform duration-(--xq-duration-slow) ease-(--xq-ease-spring) data-[state=checked]:translate-x-[calc(100%-1px)] motion-reduce:transition-none"
|
|
25
25
|
/>
|
|
26
26
|
</SwitchRoot>
|
|
27
27
|
</template>
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
|
3
|
+
import type { HTMLAttributes } from 'vue';
|
|
4
|
+
import { cn } from '../lib/utils';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The mark on the current tab, which leans over to whichever tab is hovered or
|
|
8
|
+
* focused and settles back after. Put it first inside a `relative` tab list.
|
|
9
|
+
*/
|
|
10
|
+
const props = defineProps<{ class?: HTMLAttributes['class'] }>();
|
|
11
|
+
|
|
12
|
+
const element = ref<HTMLElement>();
|
|
13
|
+
const left = ref(0);
|
|
14
|
+
const width = ref(0);
|
|
15
|
+
const isVisible = ref(false);
|
|
16
|
+
const isGliding = ref(false);
|
|
17
|
+
|
|
18
|
+
let hoveredTab: HTMLElement | undefined;
|
|
19
|
+
let attributeObserver: MutationObserver | undefined;
|
|
20
|
+
let sizeObserver: ResizeObserver | undefined;
|
|
21
|
+
|
|
22
|
+
function measure(canGlide = true): void {
|
|
23
|
+
const tab = hoveredTab?.isConnected
|
|
24
|
+
? hoveredTab
|
|
25
|
+
: element.value?.parentElement?.querySelector<HTMLElement>(
|
|
26
|
+
'[aria-current="page"], [aria-selected="true"]',
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
isGliding.value = canGlide && isVisible.value && Boolean(tab);
|
|
30
|
+
isVisible.value = Boolean(tab);
|
|
31
|
+
|
|
32
|
+
if (tab) {
|
|
33
|
+
left.value = tab.offsetLeft;
|
|
34
|
+
width.value = tab.offsetWidth;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hoverTab(event: Event): void {
|
|
39
|
+
const tab = (event.target as HTMLElement).closest<HTMLElement>('a, button:not(:disabled)');
|
|
40
|
+
|
|
41
|
+
if (tab) {
|
|
42
|
+
hoveredTab = tab;
|
|
43
|
+
measure();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function leaveTabs(): void {
|
|
48
|
+
hoveredTab = undefined;
|
|
49
|
+
measure();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
onMounted(() => {
|
|
53
|
+
const list = element.value?.parentElement;
|
|
54
|
+
|
|
55
|
+
if (!list) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
list.addEventListener('pointerover', hoverTab);
|
|
60
|
+
list.addEventListener('pointerleave', leaveTabs);
|
|
61
|
+
list.addEventListener('focusin', hoverTab);
|
|
62
|
+
list.addEventListener('focusout', leaveTabs);
|
|
63
|
+
|
|
64
|
+
attributeObserver = new MutationObserver(() => measure());
|
|
65
|
+
attributeObserver.observe(list, {
|
|
66
|
+
subtree: true,
|
|
67
|
+
childList: true,
|
|
68
|
+
attributes: true,
|
|
69
|
+
attributeFilter: ['aria-current', 'aria-selected'],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Tabs measure nothing while hidden, and their widths shift when a web font arrives.
|
|
73
|
+
sizeObserver = new ResizeObserver(() => measure(false));
|
|
74
|
+
sizeObserver.observe(list);
|
|
75
|
+
document.fonts?.ready.then(() => measure(false));
|
|
76
|
+
|
|
77
|
+
measure(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
onBeforeUnmount(() => {
|
|
81
|
+
const list = element.value?.parentElement;
|
|
82
|
+
|
|
83
|
+
list?.removeEventListener('pointerover', hoverTab);
|
|
84
|
+
list?.removeEventListener('pointerleave', leaveTabs);
|
|
85
|
+
list?.removeEventListener('focusin', hoverTab);
|
|
86
|
+
list?.removeEventListener('focusout', leaveTabs);
|
|
87
|
+
attributeObserver?.disconnect();
|
|
88
|
+
sizeObserver?.disconnect();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const style = computed(() => ({
|
|
92
|
+
transform: `translateX(${left.value}px)`,
|
|
93
|
+
width: `${width.value}px`,
|
|
94
|
+
opacity: isVisible.value ? 1 : 0,
|
|
95
|
+
}));
|
|
96
|
+
</script>
|
|
97
|
+
|
|
98
|
+
<template>
|
|
99
|
+
<span
|
|
100
|
+
ref="element"
|
|
101
|
+
aria-hidden="true"
|
|
102
|
+
:class="
|
|
103
|
+
cn(
|
|
104
|
+
'left-0 pointer-events-none absolute',
|
|
105
|
+
isGliding
|
|
106
|
+
? 'transition-[transform,width,opacity] duration-(--xq-duration-base) ease-(--xq-ease-out)'
|
|
107
|
+
: 'transition-opacity duration-(--xq-duration-fast)',
|
|
108
|
+
props.class,
|
|
109
|
+
)
|
|
110
|
+
"
|
|
111
|
+
:style="style"
|
|
112
|
+
data-xq-tab-glide
|
|
113
|
+
></span>
|
|
114
|
+
</template>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { TabsList } from 'reka-ui';
|
|
3
3
|
import type { HTMLAttributes } from 'vue';
|
|
4
4
|
import { cn } from '../lib/utils';
|
|
5
|
+
import XqTabGlide from './XqTabGlide.vue';
|
|
5
6
|
|
|
6
7
|
const props = defineProps<{ class?: HTMLAttributes['class'] }>();
|
|
7
8
|
</script>
|
|
@@ -11,11 +12,15 @@ const props = defineProps<{ class?: HTMLAttributes['class'] }>();
|
|
|
11
12
|
data-slot="xq-tabs-list"
|
|
12
13
|
:class="
|
|
13
14
|
cn(
|
|
14
|
-
'h-9 gap-1 p-1 inline-flex w-fit max-w-full items-center overflow-x-auto rounded-xq-lg bg-xq-muted text-xq-muted-foreground',
|
|
15
|
+
'h-9 gap-1 p-1 relative inline-flex w-fit max-w-full items-center overflow-x-auto rounded-xq-lg bg-xq-muted text-xq-muted-foreground',
|
|
15
16
|
props.class,
|
|
16
17
|
)
|
|
17
18
|
"
|
|
18
19
|
>
|
|
20
|
+
<XqTabGlide
|
|
21
|
+
class="inset-y-1 shadow-sm rounded-xq-md bg-xq-background"
|
|
22
|
+
data-slot="xq-tabs-indicator"
|
|
23
|
+
/>
|
|
19
24
|
<slot />
|
|
20
25
|
</TabsList>
|
|
21
26
|
</template>
|
|
@@ -17,7 +17,7 @@ const props = defineProps<{
|
|
|
17
17
|
:disabled="disabled"
|
|
18
18
|
:class="
|
|
19
19
|
cn(
|
|
20
|
-
'gap-1.5 px-3 text-sm font-medium
|
|
20
|
+
'gap-1.5 px-3 text-sm font-medium relative z-10 inline-flex h-full cursor-pointer items-center justify-center rounded-xq-md whitespace-nowrap transition-[color,box-shadow] outline-none hover:text-xq-foreground focus-visible:ring-[3px] focus-visible:ring-xq-ring/30 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-xq-foreground',
|
|
21
21
|
props.class,
|
|
22
22
|
)
|
|
23
23
|
"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* utilities, so they never collide with an app's own design tokens.
|
|
4
4
|
*/
|
|
5
5
|
export const controlClass =
|
|
6
|
-
'w-full min-w-0 rounded-xq-md border border-xq-input bg-xq-
|
|
6
|
+
'w-full min-w-0 rounded-xq-md border border-xq-input bg-xq-card text-sm text-xq-foreground shadow-xs transition-[color,box-shadow,border-color] outline-none placeholder:text-xq-muted-foreground focus-visible:border-xq-ring focus-visible:ring-[3px] focus-visible:ring-xq-ring/25 disabled:cursor-not-allowed disabled:opacity-60 aria-invalid:border-xq-destructive aria-invalid:ring-xq-destructive/20';
|
|
7
7
|
|
|
8
8
|
/** How an action's color looks on a button. */
|
|
9
9
|
export const actionVariants: Record<string, 'default' | 'destructive' | 'outline'> = {
|
|
@@ -15,18 +15,18 @@ export const actionVariants: Record<string, 'default' | 'destructive' | 'outline
|
|
|
15
15
|
export const spinnerClass = 'size-4 shrink-0 motion-safe:animate-spin text-xq-muted-foreground';
|
|
16
16
|
|
|
17
17
|
export const popoverClass =
|
|
18
|
-
'z-50 overflow-hidden rounded-xq-md border border-xq-border bg-xq-popover text-xq-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-
|
|
18
|
+
'z-50 overflow-hidden rounded-xq-md border border-xq-border bg-xq-popover text-xq-popover-foreground shadow-lg origin-(--reka-popper-transform-origin) data-[state=open]:animate-xq-pop-in data-[state=closed]:animate-xq-pop-out';
|
|
19
19
|
|
|
20
20
|
export const groupLabelClass = 'px-2 py-1.5 text-xs font-medium text-xq-muted-foreground';
|
|
21
21
|
|
|
22
22
|
export const optionClass =
|
|
23
|
-
'relative flex w-full cursor-default items-center gap-2 rounded-xq-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:
|
|
23
|
+
'relative flex w-full cursor-default items-center gap-2 rounded-xq-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:text-xq-accent-foreground';
|
|
24
24
|
|
|
25
25
|
export const dialogOverlayClass =
|
|
26
|
-
'inset-0 bg-black/50 fixed z-50 backdrop-blur-[2px]
|
|
26
|
+
'inset-0 bg-black/50 fixed z-50 backdrop-blur-[2px] data-[state=open]:animate-xq-fade-in data-[state=closed]:animate-xq-fade-out';
|
|
27
27
|
|
|
28
28
|
export const dialogContentClass =
|
|
29
|
-
'shadow-xl fixed top-1/2 left-1/2 z-50 flex max-h-[85vh] w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xq-xl border border-xq-border bg-xq-popover text-xq-popover-foreground outline-none
|
|
29
|
+
'shadow-xl fixed top-1/2 left-1/2 z-50 flex max-h-[85vh] w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xq-xl border border-xq-border bg-xq-popover text-xq-popover-foreground outline-none data-[state=open]:animate-xq-dialog-in data-[state=closed]:animate-xq-dialog-out';
|
|
30
30
|
|
|
31
31
|
/** The tinted circle behind a dialog's icon. */
|
|
32
32
|
export const dialogIconClasses: Record<string, string> = {
|
package/resources/js/ui/index.ts
CHANGED
|
@@ -13,10 +13,12 @@ export { default as XqLabel } from './XqLabel.vue';
|
|
|
13
13
|
export { default as XqPopover } from './XqPopover.vue';
|
|
14
14
|
export { default as XqRadioGroup } from './XqRadioGroup.vue';
|
|
15
15
|
export { default as XqRadioGroupItem } from './XqRadioGroupItem.vue';
|
|
16
|
+
export { default as XqMenuGlide } from './XqMenuGlide.vue';
|
|
16
17
|
export { default as XqSelect } from './XqSelect.vue';
|
|
17
18
|
export { default as XqSwitch } from './XqSwitch.vue';
|
|
18
19
|
export { default as XqTabs } from './XqTabs.vue';
|
|
19
20
|
export { default as XqTabsContent } from './XqTabsContent.vue';
|
|
21
|
+
export { default as XqTabGlide } from './XqTabGlide.vue';
|
|
20
22
|
export { default as XqTabsList } from './XqTabsList.vue';
|
|
21
23
|
export { default as XqTabsTrigger } from './XqTabsTrigger.vue';
|
|
22
24
|
export { default as XqTextarea } from './XqTextarea.vue';
|