@revojs/ui 0.2.9 → 0.2.10

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.
Files changed (36) hide show
  1. package/dist/components/container.d.ts +2 -0
  2. package/dist/components/icon.d.ts +1 -0
  3. package/dist/components/separator.d.ts +1 -6
  4. package/dist/components/stack.d.ts +12 -0
  5. package/dist/components/stack.js +1 -0
  6. package/dist/index.d.ts +1 -0
  7. package/dist/module/index.js +10 -10
  8. package/dist/style.css +7 -0
  9. package/dist/vue/components/breadcrumb.vue +12 -4
  10. package/dist/vue/components/button.vue +8 -3
  11. package/dist/vue/components/card.vue +18 -15
  12. package/dist/vue/components/checkbox.vue +6 -5
  13. package/dist/vue/components/collection.vue +13 -5
  14. package/dist/vue/components/container.vue +4 -3
  15. package/dist/vue/components/counter.vue +4 -3
  16. package/dist/vue/components/dialog.vue +7 -1
  17. package/dist/vue/components/field.vue +4 -3
  18. package/dist/vue/components/icon.vue +6 -2
  19. package/dist/vue/components/input.vue +11 -5
  20. package/dist/vue/components/popover.vue +54 -56
  21. package/dist/vue/components/progress.vue +7 -6
  22. package/dist/vue/components/radio.vue +7 -6
  23. package/dist/vue/components/range.vue +7 -6
  24. package/dist/vue/components/rating.vue +6 -5
  25. package/dist/vue/components/select.vue +78 -95
  26. package/dist/vue/components/sheet.vue +10 -4
  27. package/dist/vue/components/stack.vue +70 -0
  28. package/dist/vue/components/switch.vue +8 -7
  29. package/dist/vue/components/upload.vue +9 -5
  30. package/dist/vue/index.d.ts +3 -1
  31. package/dist/vue/index.js +5 -2
  32. package/dist/vue/utilities/use-focus-trap.d.ts +2 -0
  33. package/dist/vue/utilities/use-focus-trap.js +34 -0
  34. package/dist/vue/utilities/use-scroll-lock.d.ts +2 -0
  35. package/dist/vue/utilities/use-scroll-lock.js +33 -0
  36. package/package.json +1 -1
@@ -1,29 +1,30 @@
1
1
  <template>
2
- <div class="flex flex-col gap-2">
3
- <div v-if="label" class="flex items-center justify-between gap-4 text-sm">
2
+ <Stack>
3
+ <Stack v-if="label" direction="horizontal" align="center" justify="between" gap="1" class="text-sm">
4
4
  <span class="font-medium">{{ label }}</span>
5
5
 
6
6
  <span class="tabular-nums opacity-60">{{ Math.round(percent) }}%</span>
7
- </div>
7
+ </Stack>
8
8
 
9
9
  <progress class="sr-only" :value="value" :max="max">{{ percent }}%</progress>
10
10
 
11
11
  <div
12
- class="h-2 w-full overflow-hidden rounded-full bg-input"
12
+ class="h-2 w-full overflow-hidden rounded-ui bg-input"
13
13
  role="progressbar"
14
14
  :aria-label="label"
15
15
  :aria-valuenow="value"
16
16
  aria-valuemin="0"
17
17
  :aria-valuemax="max"
18
18
  >
19
- <div class="h-full rounded-full bg-primary transition-[width]" :style="{ width: `${percent}%` }" />
19
+ <div class="h-full rounded-ui bg-primary transition-[width]" :style="{ width: `${percent}%` }" />
20
20
  </div>
21
- </div>
21
+ </Stack>
22
22
  </template>
23
23
 
24
24
  <script setup lang="ts">
25
25
  import { computed } from "vue";
26
26
  import type { ProgressProps } from "../../components/progress";
27
+ import Stack from "./stack.vue";
27
28
 
28
29
  const { value = 0, max = 100, label } = defineProps<ProgressProps>();
29
30
 
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <label class="inline-flex items-start gap-3" :class="interactionVariants[interaction]">
2
+ <Stack is="label" direction="horizontal" align="start" gap="3" :class="interactionVariants[interaction]">
3
3
  <input
4
4
  class="peer sr-only"
5
5
  type="radio"
@@ -13,26 +13,27 @@
13
13
 
14
14
  <span
15
15
  aria-hidden="true"
16
- class="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border transition-colors peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-ring"
16
+ class="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-ui border transition-colors peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-ring"
17
17
  :class="modelValue === value ? 'border-primary bg-background' : 'border-input bg-background'"
18
18
  >
19
- <span v-if="modelValue === value" class="size-2 rounded-full bg-primary" />
19
+ <span v-if="modelValue === value" class="size-2 rounded-ui bg-primary" />
20
20
  </span>
21
21
 
22
- <span class="flex flex-col gap-0.5">
22
+ <Stack is="span" gap="0">
23
23
  <span class="text-sm font-medium">
24
24
  <slot> {{ label }}</slot>
25
25
  </span>
26
26
 
27
27
  <span v-if="description" class="text-sm opacity-60">{{ description }}</span>
28
- </span>
29
- </label>
28
+ </Stack>
29
+ </Stack>
30
30
  </template>
31
31
 
32
32
  <script setup lang="ts">
33
33
  import { computed } from "vue";
34
34
  import type { ControlInteraction } from "../../components/control.ts";
35
35
  import type { RadioProps, RadioValue } from "../../components/radio";
36
+ import Stack from "./stack.vue";
36
37
 
37
38
  const { modelValue, value, label, description, name, required = false, disabled = false } = defineProps<RadioProps>();
38
39
 
@@ -1,16 +1,16 @@
1
1
  <template>
2
- <div class="flex flex-col gap-2" :class="interactionVariants[interaction]">
3
- <span v-if="label" class="flex items-center justify-between gap-4 text-sm">
2
+ <Stack :class="interactionVariants[interaction]">
3
+ <Stack v-if="label" direction="horizontal" align="center" justify="between" gap="1" class="text-sm">
4
4
  <span class="font-medium"> {{ label }} </span>
5
5
 
6
6
  <output class="tabular-nums opacity-60">
7
7
  {{ modelValue }}
8
8
  </output>
9
- </span>
9
+ </Stack>
10
10
 
11
11
  <div
12
12
  ref="track"
13
- class="relative h-2 w-full touch-none rounded-full bg-input outline-ring focus-visible:outline-2 focus-visible:outline-offset-2"
13
+ class="relative h-2 w-full touch-none rounded-ui bg-input outline-ring focus-visible:outline-2 focus-visible:outline-offset-2"
14
14
  :class="disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'"
15
15
  role="slider"
16
16
  :tabindex="disabled ? -1 : 0"
@@ -28,17 +28,18 @@
28
28
  <div class="absolute inset-y-0 left-0 rounded-full bg-primary" :style="{ width: `${percent}%` }" />
29
29
 
30
30
  <span
31
- class="absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-primary bg-background shadow"
31
+ class="absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-ui border-2 border-primary bg-background shadow"
32
32
  :style="{ left: `${percent}%` }"
33
33
  />
34
34
  </div>
35
- </div>
35
+ </Stack>
36
36
  </template>
37
37
 
38
38
  <script setup lang="ts">
39
39
  import { computed, useTemplateRef } from "vue";
40
40
  import type { ControlInteraction } from "../../components/control.ts";
41
41
  import type { RangeProps } from "../../components/range";
42
+ import Stack from "./stack.vue";
42
43
 
43
44
  const { modelValue = 0, label, min = 0, max = 100, step = 1, disabled = false } = defineProps<RangeProps>();
44
45
 
@@ -1,12 +1,12 @@
1
1
  <template>
2
- <div class="inline-flex flex-col gap-2">
2
+ <Stack>
3
3
  <span v-if="label" class="text-sm font-medium">{{ label }}</span>
4
4
 
5
- <div class="flex items-center" role="radiogroup" :aria-label="label">
5
+ <Stack direction="horizontal" align="center" gap="0" role="radiogroup" :aria-label="label">
6
6
  <Button
7
7
  v-for="value in max"
8
8
  :key="value"
9
- class="text-2xl transition-transform hover:scale-110 disabled:hover:scale-100"
9
+ class="text-2xl"
10
10
  :class="variants[state(value)]"
11
11
  size="icon"
12
12
  variant="ghost"
@@ -18,14 +18,15 @@
18
18
  >
19
19
  <Icon :is="StarIcon" />
20
20
  </Button>
21
- </div>
22
- </div>
21
+ </Stack>
22
+ </Stack>
23
23
  </template>
24
24
 
25
25
  <script setup lang="ts">
26
26
  import StarIcon from "~icons/hugeicons/star";
27
27
  import Button from "./button.vue";
28
28
  import Icon from "./icon.vue";
29
+ import Stack from "./stack.vue";
29
30
  import type { RatingProps, RatingState } from "../../components/rating";
30
31
 
31
32
  const { modelValue = 0, max = 5, label, readonly = false, disabled = false } = defineProps<RatingProps>();
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="flex flex-col gap-2">
2
+ <Stack>
3
3
  <span v-if="label" class="text-sm font-medium">
4
4
  {{ label }}
5
5
  </span>
@@ -7,9 +7,10 @@
7
7
  <input v-if="name" type="hidden" :name="name" :value="modelValue" :required="required" />
8
8
 
9
9
  <div ref="container" class="relative">
10
- <div
11
- ref="trigger"
12
- class="flex min-h-10 w-full cursor-text items-center gap-2 rounded-ui border border-input px-3 text-sm ring-ring focus-within:ring-2"
10
+ <Stack
11
+ direction="horizontal"
12
+ align="center"
13
+ class="min-h-10 w-full cursor-text rounded-ui border border-input px-3 text-sm ring-ring focus-within:ring-2"
13
14
  :class="disabled ? 'cursor-not-allowed opacity-50' : ''"
14
15
  @click="search?.focus()"
15
16
  >
@@ -21,13 +22,15 @@
21
22
  :placeholder="placeholder ?? 'Select'"
22
23
  :aria-expanded="open"
23
24
  :aria-controls="listboxId"
25
+ :aria-activedescendant="activeId"
24
26
  aria-autocomplete="list"
25
27
  :disabled="disabled"
26
28
  @focus="open = true"
27
- @input="open = true"
28
- @keydown.enter.prevent="open ? filtered.length > 0 && select(filtered[0]) : (open = true)"
29
+ @input="input"
30
+ @keydown.enter.prevent="confirm"
29
31
  @keydown.escape="open = false"
30
- @keydown.arrow-down.prevent="open = true"
32
+ @keydown.arrow-down.prevent="stepHighlight(1)"
33
+ @keydown.arrow-up.prevent="stepHighlight(-1)"
31
34
  />
32
35
 
33
36
  <button
@@ -38,24 +41,22 @@
38
41
  :disabled="disabled"
39
42
  @click.stop="open = !open"
40
43
  >
41
- <Icon :is="ArrowDownIcon" />
44
+ <Icon :is="ArrowDownIcon" :size="iconSize" />
42
45
  </button>
43
- </div>
46
+ </Stack>
44
47
 
45
48
  <div
46
- ref="dropdown"
47
- :popover="popoverSupported ? 'manual' : undefined"
49
+ v-if="open"
48
50
  :id="listboxId"
49
51
  role="listbox"
50
52
  :aria-label="label"
51
- class="m-0 max-h-[min(16rem,50vh)] overflow-y-auto rounded-ui border border-border bg-popover p-1 text-popover-foreground shadow-xl"
52
- :class="fallbackClass"
53
- :style="popoverSupported ? panel : undefined"
53
+ class="absolute inset-x-0 top-full z-10 mt-1 max-h-[min(16rem,50vh)] overflow-y-auto rounded-ui border border-border bg-popover p-1 text-popover-foreground shadow-xl"
54
54
  >
55
55
  <Button
56
- v-for="option in filtered"
56
+ v-for="(option, index) in filtered"
57
57
  :key="option.value"
58
- class="w-full"
58
+ :id="`${listboxId}-${index}`"
59
+ :class="['w-full', highlighted === index ? 'bg-accent!' : '']"
59
60
  variant="ghost"
60
61
  layout="left"
61
62
  :disabled="option.disabled"
@@ -63,30 +64,32 @@
63
64
  :aria-selected="option.value === modelValue"
64
65
  @click="select(option)"
65
66
  >
66
- <span class="flex w-full items-center justify-between gap-4 text-left">
67
+ <Stack direction="horizontal" align="center" justify="between" gap="1" class="w-full text-left">
67
68
  <slot name="option" :option="option" :selected="option.value === modelValue">
68
69
  {{ option.label }}
69
70
  </slot>
70
71
 
71
- <Icon v-if="option.value === modelValue" :is="TickIcon" />
72
- </span>
72
+ <Icon v-if="option.value === modelValue" :is="TickIcon" :size="iconSize" />
73
+ </Stack>
73
74
  </Button>
74
75
 
75
- <p v-if="filtered.length === 0" class="px-2 py-6 text-center text-sm opacity-60">
76
+ <p v-if="filtered.length === 0" class="px-1 py-5 text-center text-sm opacity-60">
76
77
  {{ emptyLabel }}
77
78
  </p>
78
79
  </div>
79
80
  </div>
80
- </div>
81
+ </Stack>
81
82
  </template>
82
83
 
83
84
  <script setup lang="ts">
84
85
  import ArrowDownIcon from "~icons/hugeicons/arrow-down-01";
85
86
  import TickIcon from "~icons/hugeicons/tick-02";
86
- import { computed, nextTick, onMounted, onUnmounted, ref, useId, useTemplateRef, watch } from "vue";
87
+ import { computed, onUnmounted, ref, useId, useTemplateRef, watch } from "vue";
87
88
  import type { SelectOption, SelectProps, SelectValue } from "../../components/select";
88
89
  import Button from "./button.vue";
89
90
  import Icon from "./icon.vue";
91
+ import Stack from "./stack.vue";
92
+ import { useFocusTrap } from "../utilities/use-focus-trap";
90
93
 
91
94
  const {
92
95
  modelValue,
@@ -107,40 +110,32 @@ const open = ref(false);
107
110
 
108
111
  const container = useTemplateRef("container");
109
112
 
110
- const trigger = useTemplateRef("trigger");
113
+ useFocusTrap(container, open);
111
114
 
112
115
  const search = useTemplateRef("search");
113
116
 
114
- const dropdown = useTemplateRef("dropdown");
115
-
116
117
  const listboxId = `select-${useId()}`;
117
118
 
118
- const popoverSupported = ref(true);
119
+ const iconSize = 20;
119
120
 
120
121
  const selected = computed(() => options.find((option) => option.value === modelValue));
121
122
 
122
123
  const query = ref(selected.value?.label ?? "");
123
124
 
124
- const panel = ref<Record<string, string>>({});
125
-
126
125
  const filtered = computed(() => {
127
126
  const term = query.value.trim().toLowerCase();
128
127
 
129
128
  return term === "" ? options : options.filter((option) => option.label.toLowerCase().includes(term));
130
129
  });
131
130
 
132
- const fallbackClass = computed(() => {
133
- if (popoverSupported.value) {
134
- return undefined;
135
- }
131
+ const highlighted = ref(-1);
136
132
 
137
- return open.value ? "absolute inset-x-0 top-full z-50 mt-2" : "hidden";
138
- });
133
+ const activeId = computed(() =>
134
+ open.value && highlighted.value >= 0 ? `${listboxId}-${highlighted.value}` : undefined,
135
+ );
139
136
 
140
137
  function handlePointerDown(event: PointerEvent) {
141
- const target = event.target as Node;
142
-
143
- if (!container.value?.contains(target) && !dropdown.value?.contains(target)) {
138
+ if (!container.value?.contains(event.target as Node)) {
144
139
  open.value = false;
145
140
  }
146
141
  }
@@ -152,65 +147,71 @@ function select(option: SelectOption) {
152
147
  }
153
148
  }
154
149
 
155
- function updatePanel() {
156
- const anchor = trigger.value;
157
- const list = dropdown.value;
150
+ function highlight(step: 1 | -1) {
151
+ const count = filtered.value.length;
158
152
 
159
- if (!anchor || !list) {
153
+ if (count === 0) {
160
154
  return;
161
155
  }
162
156
 
163
- const height = window.visualViewport?.height ?? window.innerHeight;
164
- const width = window.visualViewport?.width ?? window.innerWidth;
165
- const bounds = anchor.getBoundingClientRect();
166
- const listHeight = list.offsetHeight;
167
- const listWidth = bounds.width;
168
- const left = Math.max(8, Math.min(Math.max(8, bounds.left), width - listWidth - 8));
169
- const top = bounds.bottom + listHeight + 8 <= height ? bounds.bottom + 8 : Math.max(8, bounds.top - listHeight - 8);
170
-
171
- panel.value = {
172
- bottom: "auto",
173
- left: `${left}px`,
174
- position: "fixed",
175
- right: "auto",
176
- top: `${top}px`,
177
- width: `${listWidth}px`,
178
- };
157
+ let index = highlighted.value;
158
+
159
+ for (let round = 0; round < count; round++) {
160
+ index = (index + step + count) % count;
161
+
162
+ if (!filtered.value[index].disabled) {
163
+ highlighted.value = index;
164
+
165
+ document.getElementById(`${listboxId}-${index}`)?.scrollIntoView({ block: "nearest" });
166
+
167
+ return;
168
+ }
169
+ }
179
170
  }
180
171
 
181
- watch(selected, (value) => {
172
+ function stepHighlight(step: 1 | -1) {
182
173
  if (!open.value) {
183
- query.value = value?.label ?? "";
174
+ open.value = true;
175
+
176
+ return;
184
177
  }
185
- });
186
178
 
187
- watch(open, async (value) => {
188
- const list = dropdown.value;
179
+ highlight(step);
180
+ }
181
+
182
+ function confirm() {
183
+ if (!open.value) {
184
+ open.value = true;
189
185
 
190
- if (!list) {
191
186
  return;
192
187
  }
193
188
 
194
- if (value) {
195
- query.value = "";
196
-
197
- document.addEventListener("pointerdown", handlePointerDown);
189
+ const option = filtered.value[highlighted.value] ?? filtered.value[0];
198
190
 
199
- window.addEventListener("resize", updatePanel);
191
+ if (option) {
192
+ select(option);
193
+ }
194
+ }
200
195
 
201
- window.addEventListener("scroll", updatePanel, true);
196
+ function input() {
197
+ open.value = true;
202
198
 
203
- window.visualViewport?.addEventListener("resize", updatePanel);
199
+ highlighted.value = -1;
200
+ }
204
201
 
205
- if (popoverSupported.value && !list.matches(":popover-open")) {
206
- list.showPopover();
207
- }
202
+ watch(selected, (value) => {
203
+ if (!open.value) {
204
+ query.value = value?.label ?? "";
205
+ }
206
+ });
208
207
 
209
- await nextTick();
208
+ watch(open, (value) => {
209
+ if (value) {
210
+ query.value = "";
210
211
 
211
- updatePanel();
212
+ highlighted.value = filtered.value.findIndex((option) => option.value === modelValue);
212
213
 
213
- requestAnimationFrame(updatePanel);
214
+ document.addEventListener("pointerdown", handlePointerDown);
214
215
 
215
216
  if (document.activeElement !== search.value) {
216
217
  search.value?.focus();
@@ -218,31 +219,13 @@ watch(open, async (value) => {
218
219
  } else {
219
220
  document.removeEventListener("pointerdown", handlePointerDown);
220
221
 
221
- window.removeEventListener("resize", updatePanel);
222
-
223
- window.removeEventListener("scroll", updatePanel, true);
224
-
225
- window.visualViewport?.removeEventListener("resize", updatePanel);
226
-
227
- if (popoverSupported.value && list.matches(":popover-open")) {
228
- list.hidePopover();
229
- }
222
+ highlighted.value = -1;
230
223
 
231
224
  query.value = selected.value?.label ?? "";
232
225
  }
233
226
  });
234
227
 
235
- onMounted(() => {
236
- popoverSupported.value = typeof dropdown.value?.showPopover === "function";
237
- });
238
-
239
228
  onUnmounted(() => {
240
229
  document.removeEventListener("pointerdown", handlePointerDown);
241
-
242
- window.removeEventListener("resize", updatePanel);
243
-
244
- window.removeEventListener("scroll", updatePanel, true);
245
-
246
- window.visualViewport?.removeEventListener("resize", updatePanel);
247
230
  });
248
231
  </script>
@@ -34,6 +34,8 @@ import { useTemplateRef, watchEffect } from "vue";
34
34
  import type { SheetProps, SheetSide } from "../../components/sheet";
35
35
  import Button from "./button.vue";
36
36
  import Card from "./card.vue";
37
+ import { useFocusTrap } from "../utilities/use-focus-trap";
38
+ import { useScrollLock } from "../utilities/use-scroll-lock";
37
39
 
38
40
  const { modelValue = false, side = "right", title, description, closeLabel = "Close" } = defineProps<SheetProps>();
39
41
 
@@ -43,6 +45,10 @@ defineEmits<{
43
45
 
44
46
  const sheet = useTemplateRef("sheet");
45
47
 
48
+ useFocusTrap(sheet, () => modelValue);
49
+
50
+ useScrollLock(() => modelValue);
51
+
46
52
  const positions: Record<SheetSide, string> = {
47
53
  top: "bottom-auto left-0 right-0 top-0 h-auto w-full",
48
54
  right: "bottom-0 left-auto right-0 top-0 h-full max-h-full w-[min(28rem,90vw)]",
@@ -51,10 +57,10 @@ const positions: Record<SheetSide, string> = {
51
57
  };
52
58
 
53
59
  const cardClasses: Record<SheetSide, string> = {
54
- top: "max-h-[85vh] w-full shadow-2xl",
55
- right: "h-full w-full shadow-2xl",
56
- bottom: "max-h-[85vh] w-full shadow-2xl",
57
- left: "h-full w-full shadow-2xl",
60
+ top: "max-h-[85vh] w-full border-l-0 border-r-0 border-t-0 shadow-sheet-top",
61
+ right: "h-full w-full border-b-0 border-r-0 border-t-0 shadow-sheet-right",
62
+ bottom: "max-h-[85vh] w-full border-b-0 border-l-0 border-r-0 shadow-sheet-bottom",
63
+ left: "h-full w-full border-b-0 border-l-0 border-t-0 shadow-sheet-left",
58
64
  };
59
65
 
60
66
  watchEffect(() => {
@@ -0,0 +1,70 @@
1
+ <template>
2
+ <component
3
+ :is="is"
4
+ class="flex min-w-0"
5
+ :class="[
6
+ directions[direction],
7
+ gaps[gap],
8
+ paddings[padding],
9
+ aligns[align],
10
+ justifies[justify],
11
+ wrap ? 'flex-wrap' : '',
12
+ ]"
13
+ >
14
+ <slot />
15
+ </component>
16
+ </template>
17
+
18
+ <script setup lang="ts">
19
+ import type { Component } from "vue";
20
+ import type { StackAlign, StackDirection, StackGap, StackJustify, StackProps } from "../../components/stack";
21
+
22
+ const {
23
+ is = "div",
24
+ direction = "vertical",
25
+ gap = "1",
26
+ padding = "0",
27
+ align = "stretch",
28
+ justify = "start",
29
+ wrap = false,
30
+ } = defineProps<
31
+ StackProps & {
32
+ is?: string | Component;
33
+ }
34
+ >();
35
+
36
+ const directions: Record<StackDirection, string> = {
37
+ horizontal: "flex-row",
38
+ vertical: "flex-col",
39
+ };
40
+
41
+ const gaps: Record<StackGap, string> = {
42
+ "0": "gap-0",
43
+ "1": "gap-1",
44
+ "3": "gap-3",
45
+ "5": "gap-5",
46
+ "7": "gap-7",
47
+ };
48
+
49
+ const paddings: Record<StackGap, string> = {
50
+ "0": "p-0",
51
+ "1": "p-1",
52
+ "3": "p-3",
53
+ "5": "p-5",
54
+ "7": "p-7",
55
+ };
56
+
57
+ const aligns: Record<StackAlign, string> = {
58
+ center: "items-center",
59
+ end: "items-end",
60
+ start: "items-start",
61
+ stretch: "items-stretch",
62
+ };
63
+
64
+ const justifies: Record<StackJustify, string> = {
65
+ between: "justify-between",
66
+ center: "justify-center",
67
+ end: "justify-end",
68
+ start: "justify-start",
69
+ };
70
+ </script>
@@ -1,9 +1,9 @@
1
1
  <template>
2
- <label class="inline-flex items-start gap-3" :class="interactionVariants[interaction]">
2
+ <Stack is="label" direction="horizontal" align="start" gap="3" :class="interactionVariants[interaction]">
3
3
  <input v-if="name" type="hidden" :name="name" :value="modelValue ? 'on' : 'off'" />
4
4
 
5
5
  <button
6
- class="relative mt-0.5 flex h-6 w-11 shrink-0 items-center rounded-full outline-ring transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed"
6
+ class="relative mt-0.5 flex h-6 w-11 shrink-0 items-center rounded-ui outline-ring transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed"
7
7
  :class="variants[state]"
8
8
  type="button"
9
9
  role="switch"
@@ -11,23 +11,24 @@
11
11
  :disabled="disabled"
12
12
  @click="$emit('update:modelValue', !modelValue)"
13
13
  >
14
- <span class="block size-5 rounded-full shadow transition-transform" :class="thumbVariants[state]" />
14
+ <span class="block size-5 rounded-ui shadow transition-transform" :class="thumbVariants[state]" />
15
15
  </button>
16
16
 
17
- <span v-if="label || description || $slots.default" class="flex flex-col gap-0.5">
17
+ <Stack is="span" v-if="label || description || $slots.default" gap="0">
18
18
  <span class="text-sm font-medium">
19
19
  <slot> {{ label }} </slot>
20
20
  </span>
21
21
 
22
22
  <span v-if="description" class="text-sm opacity-60"> {{ description }} </span>
23
- </span>
24
- </label>
23
+ </Stack>
24
+ </Stack>
25
25
  </template>
26
26
 
27
27
  <script setup lang="ts">
28
28
  import { computed } from "vue";
29
29
  import type { ControlInteraction } from "../../components/control.ts";
30
30
  import type { SwitchProps, SwitchState } from "../../components/switch";
31
+ import Stack from "./stack.vue";
31
32
 
32
33
  const { modelValue = false, label, description, name, disabled = false } = defineProps<SwitchProps>();
33
34
 
@@ -46,7 +47,7 @@ const interactionVariants: Record<ControlInteraction, string> = {
46
47
 
47
48
  const thumbVariants: Record<SwitchState, string> = {
48
49
  checked: "translate-x-5 bg-primary-foreground",
49
- unchecked: "translate-x-0.5 bg-white",
50
+ unchecked: "translate-x-1 bg-white",
50
51
  };
51
52
 
52
53
  const variants: Record<SwitchState, string> = {
@@ -1,11 +1,14 @@
1
1
  <template>
2
- <div class="flex w-full flex-col gap-2">
2
+ <Stack>
3
3
  <span v-if="label" class="text-sm font-medium">
4
4
  {{ label }}<span v-if="required" aria-hidden="true"> *</span>
5
5
  </span>
6
6
 
7
- <div
8
- class="relative flex min-h-32 flex-col items-center justify-center gap-3 rounded-ui border border-input border-dashed p-5 text-center transition-colors"
7
+ <Stack
8
+ align="center"
9
+ justify="center"
10
+ gap="3"
11
+ class="relative min-h-32 rounded-ui border border-input border-dashed p-5 text-center transition-colors"
9
12
  :class="[variants[state], interactionVariants[interaction]]"
10
13
  @dragenter.prevent="isOver = true"
11
14
  @dragover.prevent="isOver = true"
@@ -35,10 +38,10 @@
35
38
  :disabled="disabled"
36
39
  @change="change"
37
40
  />
38
- </div>
41
+ </Stack>
39
42
 
40
43
  <span v-if="description" class="text-sm opacity-60">{{ description }}</span>
41
- </div>
44
+ </Stack>
42
45
  </template>
43
46
 
44
47
  <script setup lang="ts">
@@ -46,6 +49,7 @@ import { computed, ref, useTemplateRef } from "vue";
46
49
  import type { ControlInteraction } from "../../components/control.ts";
47
50
  import type { UploadProps, UploadState } from "../../components/upload";
48
51
  import Button from "./button.vue";
52
+ import Stack from "./stack.vue";
49
53
 
50
54
  const { modelValue, label, description, accept, name, required = false, disabled = false } = defineProps<UploadProps>();
51
55