@xelqun/forms 0.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xelqun/forms",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Vue renderer for Xelqun Forms: PHP-defined, schema-driven forms for Laravel + Inertia.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -6,12 +6,18 @@
6
6
  @source "../js";
7
7
 
8
8
  @theme inline {
9
- --animate-xq-fade-in: xq-fade-in 150ms ease-out;
10
- --animate-xq-fade-out: xq-fade-out 100ms ease-in forwards;
11
- --animate-xq-toast-in: xq-toast-in 200ms cubic-bezier(0.16, 1, 0.3, 1);
12
- --animate-xq-toast-out: xq-toast-out 150ms ease-in forwards;
13
- --animate-xq-dialog-in: xq-dialog-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
14
- --animate-xq-dialog-out: xq-dialog-out 100ms ease-in forwards;
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.5rem;
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.96;
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="showsSpinner && 'pointer-events-none'"
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
- <span :class="cn('gap-1.5 inline-flex items-center', showsSpinner && 'invisible')">
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
- </span>
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="showsSpinner && 'pointer-events-none'"
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
- <span
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
- </span>
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="cn('gap-2 grid', inlineLabel && 'sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]')"
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?: boolean | 'errors';
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-background"
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-background"
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-background 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',
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-background outline-none focus-visible:ring-[3px] focus-visible:ring-xq-ring/30"
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-background 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',
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 motion-reduce:transition-none',
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 v-show="!collapsed" class="xq-grid p-5" :style="gridStyle(options.columns)">
61
- <XqNode v-for="child in node.children ?? []" :key="child.key" :node="child" />
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?: boolean | 'errors';
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;
@@ -107,9 +107,10 @@ function mountNotifications(label?: string): () => void {
107
107
  const host = document.body.appendChild(document.createElement('div'));
108
108
  const toaster = createApp(XqNotifications, { label });
109
109
  const stopListening = router.on('flash', (event) => {
110
- const { [FLASH_KEY]: notifications } = event.detail.flash;
110
+ // An app can type its flash data, and this key is ours, not part of that type.
111
+ const flash = event.detail.flash as Record<string, unknown>;
111
112
 
112
- (notifications as XqNotification[] | undefined)?.forEach(notify);
113
+ (flash[FLASH_KEY] as XqNotification[] | undefined)?.forEach(notify);
113
114
  });
114
115
 
115
116
  toaster.mount(host);
@@ -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-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 data-[state=indeterminate]:border-xq-primary data-[state=indeterminate]:bg-xq-primary data-[state=indeterminate]:text-xq-primary-foreground',
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 v-else class="size-3.5" :stroke-width="3" />
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-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',
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 data-[state=active]:shadow-sm 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]:bg-xq-background data-[state=active]:text-xq-foreground',
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-background 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';
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-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0';
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]:bg-xq-accent data-[highlighted]:text-xq-accent-foreground';
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] motion-safe:data-[state=open]:animate-xq-fade-in motion-safe:data-[state=closed]:animate-xq-fade-out';
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 motion-safe:data-[state=open]:animate-xq-dialog-in motion-safe:data-[state=closed]:animate-xq-dialog-out';
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> = {
@@ -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';