@revojs/ui 0.2.12 → 0.2.13

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 (49) hide show
  1. package/dist/components/button.d.ts +1 -0
  2. package/dist/components/card.d.ts +8 -0
  3. package/dist/components/collection.d.ts +3 -0
  4. package/dist/components/container.d.ts +2 -3
  5. package/dist/components/dialog.d.ts +3 -2
  6. package/dist/components/entry.d.ts +6 -0
  7. package/dist/components/entry.js +1 -0
  8. package/dist/components/field.d.ts +2 -0
  9. package/dist/components/input.d.ts +1 -0
  10. package/dist/components/notification.d.ts +9 -0
  11. package/dist/components/notification.js +1 -0
  12. package/dist/components/pagination.d.ts +8 -0
  13. package/dist/components/pagination.js +1 -0
  14. package/dist/components/popover.d.ts +9 -1
  15. package/dist/components/select.d.ts +8 -5
  16. package/dist/components/sheet.d.ts +3 -2
  17. package/dist/components/shell.d.ts +6 -0
  18. package/dist/components/shell.js +1 -0
  19. package/dist/components/textarea.d.ts +13 -0
  20. package/dist/components/textarea.js +1 -0
  21. package/dist/index.d.ts +5 -0
  22. package/dist/module/index.d.ts +8 -0
  23. package/dist/module/index.js +16 -0
  24. package/dist/style.css +8 -0
  25. package/dist/vue/components/breadcrumb.vue +6 -1
  26. package/dist/vue/components/button.vue +25 -3
  27. package/dist/vue/components/card.vue +22 -3
  28. package/dist/vue/components/collection.vue +22 -7
  29. package/dist/vue/components/container.vue +6 -2
  30. package/dist/vue/components/dialog.vue +52 -35
  31. package/dist/vue/components/entry.vue +52 -0
  32. package/dist/vue/components/field.vue +37 -7
  33. package/dist/vue/components/input.vue +22 -2
  34. package/dist/vue/components/navigate.vue +1 -0
  35. package/dist/vue/components/notification.vue +58 -0
  36. package/dist/vue/components/pagination.vue +24 -0
  37. package/dist/vue/components/popover.vue +39 -15
  38. package/dist/vue/components/select.vue +93 -15
  39. package/dist/vue/components/sheet.vue +55 -21
  40. package/dist/vue/components/shell.vue +38 -0
  41. package/dist/vue/components/textarea.vue +49 -0
  42. package/dist/vue/components/upload.vue +43 -24
  43. package/dist/vue/index.d.ts +5 -0
  44. package/dist/vue/index.js +6 -1
  45. package/dist/vue/utilities/use-control-attributes.d.ts +11 -0
  46. package/dist/vue/utilities/use-control-attributes.js +14 -0
  47. package/dist/vue/utilities/use-modal-dialog.d.ts +9 -0
  48. package/dist/vue/utilities/use-modal-dialog.js +48 -0
  49. package/package.json +1 -1
@@ -1,25 +1,55 @@
1
1
  <template>
2
- <Stack is="fieldset" :disabled="disabled" :class="texts[text]">
3
- <legend v-if="label" class="mb-1 font-normal">
2
+ <Stack
3
+ :is="is ?? (controlId ? 'div' : 'fieldset')"
4
+ :disabled="is !== 'div' && !controlId ? disabled : undefined"
5
+ :class="texts[text]"
6
+ >
7
+ <component
8
+ :is="controlId ? 'label' : is === 'div' ? 'span' : 'legend'"
9
+ v-if="label"
10
+ :for="controlId"
11
+ class="font-normal"
12
+ :class="!controlId && is !== 'div' ? 'mb-1' : ''"
13
+ >
4
14
  {{ label }}<span v-if="required" aria-hidden="true" class="text-destructive"> *</span>
5
- </legend>
15
+ </component>
6
16
 
7
- <slot />
17
+ <slot :control="controlAttributes" />
8
18
 
9
- <p v-if="error" class="text-destructive">
19
+ <p v-if="error" :id="`${id}-error`" role="alert" class="text-destructive">
10
20
  {{ error }}
11
21
  </p>
12
- <p v-else-if="description" class="opacity-60">
22
+ <p v-else-if="description" :id="`${id}-description`" class="opacity-60">
13
23
  {{ description }}
14
24
  </p>
15
25
  </Stack>
16
26
  </template>
17
27
 
18
28
  <script setup lang="ts">
29
+ import { computed, useId } from "vue";
19
30
  import type { FieldProps, FieldText } from "../../components/field";
20
31
  import Stack from "./stack.vue";
21
32
 
22
- const { label, description, error, text = "sm", required = false, disabled = false } = defineProps<FieldProps>();
33
+ const {
34
+ label,
35
+ description,
36
+ error,
37
+ text = "sm",
38
+ required = false,
39
+ disabled = false,
40
+ for: controlId,
41
+ is,
42
+ } = defineProps<FieldProps>();
43
+
44
+ const id = useId();
45
+
46
+ const controlAttributes = computed(() => ({
47
+ id: controlId,
48
+ required,
49
+ disabled,
50
+ "aria-describedby": error ? `${id}-error` : description ? `${id}-description` : undefined,
51
+ "aria-invalid": error ? true : undefined,
52
+ }));
23
53
 
24
54
  const texts: Record<FieldText, string> = {
25
55
  sm: "text-sm",
@@ -1,17 +1,26 @@
1
1
  <template>
2
2
  <Stack
3
+ v-bind="rootAttributes"
3
4
  is="span"
4
5
  direction="horizontal"
5
6
  align="center"
6
7
  gap="0"
7
8
  class="w-full cursor-text rounded-ui bg-secondary p-3 ring-1 ring-input ring-inset focus-within:ring-2 focus-within:ring-ring"
8
- :class="[texts[text], interactionVariants[interaction]]"
9
+ :class="[
10
+ texts[text],
11
+ interactionVariants[interaction],
12
+ (invalid ?? (controlAttributes['aria-invalid'] === true || controlAttributes['aria-invalid'] === 'true'))
13
+ ? 'ring-2 ring-destructive'
14
+ : '',
15
+ ]"
9
16
  >
10
17
  <slot name="leading" />
11
18
 
12
19
  <Icon v-if="iconPrefix" :is="iconPrefix" :size="iconSize" />
13
20
 
14
21
  <input
22
+ v-bind="controlAttributes"
23
+ ref="control"
15
24
  class="w-full min-w-0 appearance-none bg-transparent px-1 outline-none placeholder:opacity-50"
16
25
  :value="modelValue"
17
26
  :type="type"
@@ -21,6 +30,7 @@
21
30
  :required="required"
22
31
  :readonly="readonly"
23
32
  :disabled="disabled"
33
+ :aria-invalid="invalid ?? controlAttributes['aria-invalid']"
24
34
  @input="input"
25
35
  />
26
36
 
@@ -31,11 +41,14 @@
31
41
  </template>
32
42
 
33
43
  <script setup lang="ts">
34
- import { computed } from "vue";
44
+ import { computed, useTemplateRef } from "vue";
35
45
  import type { ControlInteraction } from "../../components/control.ts";
36
46
  import type { InputProps, InputText } from "../../components/input";
37
47
  import Icon from "./icon.vue";
38
48
  import Stack from "./stack.vue";
49
+ import { useControlAttributes } from "../utilities/use-control-attributes";
50
+
51
+ defineOptions({ inheritAttrs: false });
39
52
 
40
53
  const {
41
54
  modelValue,
@@ -49,8 +62,15 @@ const {
49
62
  required = false,
50
63
  readonly = false,
51
64
  disabled = false,
65
+ invalid = undefined,
52
66
  } = defineProps<InputProps>();
53
67
 
68
+ const { rootAttributes, controlAttributes } = useControlAttributes();
69
+
70
+ const control = useTemplateRef("control");
71
+
72
+ defineExpose({ focus: () => control.value?.focus() });
73
+
54
74
  const iconSize = computed(() => (text === "sm" ? 20 : 24));
55
75
 
56
76
  const texts: Record<InputText, string> = {
@@ -8,6 +8,7 @@
8
8
  :icon-prefix="iconPrefix"
9
9
  :icon-suffix="iconSuffix"
10
10
  :label="label"
11
+ :aria-current="active ? 'page' : undefined"
11
12
  />
12
13
  </template>
13
14
 
@@ -0,0 +1,58 @@
1
+ <template>
2
+ <div
3
+ :role="announce ? (variant === 'error' ? 'alert' : 'status') : undefined"
4
+ class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3 gap-y-3 rounded-ui border p-3 text-sm"
5
+ :class="[variants[variant], $slots.actions ? 'sm:grid-cols-[auto_minmax(0,1fr)_auto]' : '']"
6
+ >
7
+ <Stack gap="0" class="shrink-0">
8
+ <slot name="icon">
9
+ <Icon :is="icon ?? icons[variant]" :size="20" />
10
+ </slot>
11
+ </Stack>
12
+
13
+ <Stack gap="1" class="min-w-0">
14
+ <p v-if="label" class="text-sm/5 font-medium">{{ label }}</p>
15
+
16
+ <div v-if="subtext || $slots.default" class="text-sm/5 text-muted-foreground">
17
+ <slot>{{ subtext }}</slot>
18
+ </div>
19
+ </Stack>
20
+
21
+ <Stack
22
+ v-if="$slots.actions"
23
+ direction="horizontal"
24
+ align="center"
25
+ gap="1"
26
+ wrap
27
+ class="col-start-2 sm:col-start-auto"
28
+ >
29
+ <slot name="actions" />
30
+ </Stack>
31
+ </div>
32
+ </template>
33
+
34
+ <script setup lang="ts">
35
+ import AlertIcon from "~icons/hugeicons/alert-circle";
36
+ import CheckmarkIcon from "~icons/hugeicons/checkmark-circle-02";
37
+ import InformationIcon from "~icons/hugeicons/information-circle";
38
+ import type { IconComponent } from "../../components/icon";
39
+ import type { NotificationProps, NotificationVariant } from "../../components/notification";
40
+ import Icon from "./icon.vue";
41
+ import Stack from "./stack.vue";
42
+
43
+ const { label, subtext, icon, variant = "info", announce = true } = defineProps<NotificationProps>();
44
+
45
+ const icons: Record<NotificationVariant, IconComponent> = {
46
+ error: AlertIcon,
47
+ attention: AlertIcon,
48
+ success: CheckmarkIcon,
49
+ info: InformationIcon,
50
+ };
51
+
52
+ const variants: Record<NotificationVariant, string> = {
53
+ error: "border-destructive/30 bg-destructive/5 text-destructive",
54
+ attention: "border-attention/30 bg-attention/5 text-attention",
55
+ success: "border-success/30 bg-success/5 text-success",
56
+ info: "border-border bg-muted/50 text-foreground",
57
+ };
58
+ </script>
@@ -0,0 +1,24 @@
1
+ <template>
2
+ <Stack is="nav" :aria-label="label" direction="horizontal" justify="end" gap="1" :aria-busy="loading || undefined">
3
+ <Button :label="previousLabel" variant="ghost" :disabled="!hasPrevious || loading" @click="$emit('previous')" />
4
+
5
+ <Button :label="nextLabel" variant="ghost" :disabled="!hasNext || loading" @click="$emit('next')" />
6
+ </Stack>
7
+ </template>
8
+
9
+ <script setup lang="ts">
10
+ import type { PaginationProps } from "../../components/pagination";
11
+ import Button from "./button.vue";
12
+ import Stack from "./stack.vue";
13
+
14
+ const {
15
+ hasPrevious = false,
16
+ hasNext = false,
17
+ loading = false,
18
+ previousLabel = "Previous",
19
+ nextLabel = "Next",
20
+ label = "Pagination",
21
+ } = defineProps<PaginationProps>();
22
+
23
+ defineEmits<{ previous: []; next: [] }>();
24
+ </script>
@@ -1,6 +1,18 @@
1
1
  <template>
2
- <div ref="container" class="relative w-full">
3
- <Button variant="outline" :class="triggerClass" :layout="triggerLayout" :disabled="disabled" @click="toggle">
2
+ <div
3
+ ref="container"
4
+ class="relative w-full"
5
+ @keydown="handleKeyDown"
6
+ @focusout="$event.relatedTarget && !container?.contains($event.relatedTarget as Node) && close()"
7
+ >
8
+ <Button
9
+ :variant="triggerVariant"
10
+ :class="triggerClass"
11
+ :layout="triggerLayout"
12
+ :disabled="disabled"
13
+ :aria-expanded="isOpen"
14
+ @click="toggle"
15
+ >
4
16
  <slot name="trigger" :open="open" :close="close" :toggle="toggle">
5
17
  <span class="truncate px-1" :class="triggerLayout === 'center' ? '' : 'flex-1'">{{
6
18
  triggerLabel
@@ -8,13 +20,19 @@
8
20
  </slot>
9
21
  </Button>
10
22
 
11
- <div
23
+ <Stack
12
24
  v-if="isOpen"
25
+ :padding="contentPadding"
26
+ :gap="contentGap"
27
+ :direction="contentDirection"
28
+ :align="contentAlign"
29
+ :justify="contentJustify"
30
+ :wrap="contentWrap"
13
31
  class="absolute left-0 top-full z-10 mt-1 max-w-xs rounded-ui border border-border bg-popover text-popover-foreground shadow-xl"
14
32
  :class="[contentClass, matchTriggerWidth ? 'w-full' : '']"
15
33
  >
16
34
  <slot :open="open" :close="close" :toggle="toggle" />
17
- </div>
35
+ </Stack>
18
36
  </div>
19
37
  </template>
20
38
 
@@ -22,14 +40,21 @@
22
40
  import { onUnmounted, ref, useTemplateRef, watch } from "vue";
23
41
  import type { PopoverProps } from "../../components/popover";
24
42
  import Button from "./button.vue";
25
- import { useFocusTrap } from "../utilities/use-focus-trap";
43
+ import Stack from "./stack.vue";
26
44
 
27
45
  const {
28
46
  modelValue = false,
29
47
  triggerLabel = "Open",
30
48
  triggerClass,
31
49
  triggerLayout = "center",
32
- contentClass = "flex flex-col gap-1 p-1",
50
+ triggerVariant = "outline",
51
+ contentClass,
52
+ contentPadding = "1",
53
+ contentGap = "1",
54
+ contentDirection,
55
+ contentAlign,
56
+ contentJustify,
57
+ contentWrap,
33
58
  matchTriggerWidth = true,
34
59
  disabled = false,
35
60
  } = defineProps<PopoverProps>();
@@ -42,8 +67,6 @@ const container = useTemplateRef("container");
42
67
 
43
68
  const isOpen = ref(modelValue);
44
69
 
45
- useFocusTrap(container, isOpen);
46
-
47
70
  function setOpen(value: boolean) {
48
71
  isOpen.value = value;
49
72
 
@@ -69,22 +92,25 @@ function handlePointerDown(event: PointerEvent) {
69
92
  }
70
93
 
71
94
  function handleKeyDown(event: KeyboardEvent) {
72
- if (event.key === "Escape") {
95
+ if (event.key === "Escape" && isOpen.value) {
96
+ event.preventDefault();
97
+ event.stopPropagation();
73
98
  close();
99
+ container.value?.querySelector<HTMLButtonElement>("button")?.focus();
74
100
  }
75
101
  }
76
102
 
77
103
  watch(
78
104
  isOpen,
79
105
  (value) => {
106
+ if (typeof document === "undefined") {
107
+ return;
108
+ }
109
+
80
110
  if (value) {
81
111
  document.addEventListener("pointerdown", handlePointerDown);
82
-
83
- document.addEventListener("keydown", handleKeyDown);
84
112
  } else {
85
113
  document.removeEventListener("pointerdown", handlePointerDown);
86
-
87
- document.removeEventListener("keydown", handleKeyDown);
88
114
  }
89
115
  },
90
116
  { immediate: true },
@@ -99,8 +125,6 @@ watch(
99
125
 
100
126
  onUnmounted(() => {
101
127
  document.removeEventListener("pointerdown", handlePointerDown);
102
-
103
- document.removeEventListener("keydown", handleKeyDown);
104
128
  });
105
129
 
106
130
  defineExpose({ open, close, toggle });
@@ -1,17 +1,56 @@
1
1
  <template>
2
- <Stack>
3
- <span v-if="label" class="text-sm font-normal">
2
+ <Stack v-bind="rootAttributes">
3
+ <label v-if="label" :for="controlAttributes.id ?? controlId" class="text-sm font-normal">
4
4
  {{ label }}
5
- </span>
6
-
7
- <input v-if="name" type="hidden" :name="name" :value="modelValue" :required="required" />
5
+ </label>
6
+
7
+ <input v-if="name && searchable" type="hidden" :name="name" :value="modelValue" :disabled="disabled" />
8
+
9
+ <select
10
+ v-if="!searchable"
11
+ v-bind="controlAttributes"
12
+ ref="nativeControl"
13
+ :id="controlAttributes.id ?? controlId"
14
+ :value="modelValue ?? ''"
15
+ :name="name"
16
+ :required="required"
17
+ :disabled="disabled"
18
+ :aria-invalid="invalid ?? controlAttributes['aria-invalid']"
19
+ class="w-full rounded-ui bg-secondary p-3 text-sm ring-1 ring-input ring-inset outline-ring focus-visible:outline-2 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-2 aria-invalid:ring-destructive"
20
+ @change="
21
+ const option = options.find(
22
+ (option) => String(option.value) === ($event.currentTarget as HTMLSelectElement).value,
23
+ );
24
+ if (option) {
25
+ select(option);
26
+ }
27
+ "
28
+ >
29
+ <option v-if="modelValue === undefined" value="" disabled>{{ placeholder ?? "Select" }}</option>
30
+
31
+ <option
32
+ v-for="option in options"
33
+ :key="option.value"
34
+ :value="option.value"
35
+ :selected="option.value === modelValue"
36
+ :disabled="option.disabled"
37
+ >
38
+ {{ option.label }}
39
+ </option>
40
+ </select>
8
41
 
9
- <div ref="container" class="relative">
42
+ <div v-else ref="container" class="relative">
10
43
  <Stack
11
44
  direction="horizontal"
12
45
  align="center"
13
46
  class="w-full cursor-text rounded-ui bg-secondary p-3 text-sm font-semibold ring-1 ring-input ring-inset focus-within:ring-2 focus-within:ring-ring"
14
- :class="disabled ? 'cursor-not-allowed opacity-50' : ''"
47
+ :class="[
48
+ disabled ? 'cursor-not-allowed opacity-50' : '',
49
+ (invalid ??
50
+ (controlAttributes['aria-invalid'] === true || controlAttributes['aria-invalid'] === 'true'))
51
+ ? 'ring-2 ring-destructive'
52
+ : '',
53
+ ]"
15
54
  @click="
16
55
  if (!disabled) {
17
56
  open = true;
@@ -20,6 +59,8 @@
20
59
  "
21
60
  >
22
61
  <input
62
+ v-bind="controlAttributes"
63
+ :id="controlAttributes.id ?? controlId"
23
64
  ref="search"
24
65
  v-model="query"
25
66
  role="combobox"
@@ -30,9 +71,13 @@
30
71
  :aria-activedescendant="activeId"
31
72
  aria-autocomplete="list"
32
73
  :disabled="disabled"
74
+ :required="required"
75
+ :aria-invalid="invalid ?? controlAttributes['aria-invalid']"
76
+ @invalid="open = true"
33
77
  @input="input"
34
78
  @keydown.enter.prevent="confirm"
35
- @keydown.escape="open = false"
79
+ @keydown.escape="escape"
80
+ @keydown.tab="open = false"
36
81
  @keydown.arrow-down.prevent="stepHighlight(1)"
37
82
  @keydown.arrow-up.prevent="stepHighlight(-1)"
38
83
  />
@@ -58,6 +103,7 @@
58
103
  variant="ghost"
59
104
  layout="left"
60
105
  :disabled="option.disabled"
106
+ tabindex="-1"
61
107
  role="option"
62
108
  :aria-selected="option.value === modelValue"
63
109
  @click="select(option)"
@@ -79,16 +125,18 @@
79
125
  </Stack>
80
126
  </template>
81
127
 
82
- <script setup lang="ts">
128
+ <script setup lang="ts" generic="Value extends SelectValue = SelectValue">
83
129
  import ArrowDownIcon from "~icons/hugeicons/arrow-down-01";
84
130
  import ArrowUpIcon from "~icons/hugeicons/arrow-up-01";
85
131
  import TickIcon from "~icons/hugeicons/tick-02";
86
- import { computed, onUnmounted, ref, useId, useTemplateRef, watch } from "vue";
132
+ import { computed, onUnmounted, ref, useId, useTemplateRef, watch, watchEffect } from "vue";
87
133
  import type { SelectOption, SelectProps, SelectValue } from "../../components/select";
88
134
  import Button from "./button.vue";
89
135
  import Icon from "./icon.vue";
90
136
  import Stack from "./stack.vue";
91
- import { useFocusTrap } from "../utilities/use-focus-trap";
137
+ import { useControlAttributes } from "../utilities/use-control-attributes";
138
+
139
+ defineOptions({ inheritAttrs: false });
92
140
 
93
141
  const {
94
142
  modelValue,
@@ -99,22 +147,31 @@ const {
99
147
  name,
100
148
  required = false,
101
149
  disabled = false,
102
- } = defineProps<SelectProps>();
150
+ searchable = true,
151
+ invalid = undefined,
152
+ requiredLabel = "Please select an option",
153
+ } = defineProps<SelectProps<Value>>();
103
154
 
104
155
  const emit = defineEmits<{
105
- "update:modelValue": [value: SelectValue];
156
+ "update:modelValue": [value: Value];
106
157
  }>();
107
158
 
108
159
  const open = ref(false);
109
160
 
110
161
  const container = useTemplateRef("container");
111
162
 
112
- useFocusTrap(container, open);
163
+ const { rootAttributes, controlAttributes } = useControlAttributes();
113
164
 
114
165
  const search = useTemplateRef("search");
115
166
 
167
+ const nativeControl = useTemplateRef("nativeControl");
168
+
116
169
  const listboxId = `select-${useId()}`;
117
170
 
171
+ const controlId = `${listboxId}-control`;
172
+
173
+ defineExpose({ focus: () => (search.value ?? nativeControl.value)?.focus() });
174
+
118
175
  const iconSize = 20;
119
176
 
120
177
  const selected = computed(() => options.find((option) => option.value === modelValue));
@@ -141,10 +198,19 @@ function handlePointerDown(event: PointerEvent) {
141
198
  }
142
199
  }
143
200
 
144
- function select(option: SelectOption) {
201
+ function escape(event: KeyboardEvent) {
202
+ if (open.value) {
203
+ event.preventDefault();
204
+ event.stopPropagation();
205
+ open.value = false;
206
+ }
207
+ }
208
+
209
+ function select(option: SelectOption<Value>) {
145
210
  if (!option.disabled) {
146
211
  emit("update:modelValue", option.value);
147
212
  open.value = false;
213
+ search.value?.focus();
148
214
  }
149
215
  }
150
216
 
@@ -195,6 +261,10 @@ function confirm() {
195
261
  }
196
262
 
197
263
  function input() {
264
+ if (!searchable) {
265
+ return;
266
+ }
267
+
198
268
  searching.value = true;
199
269
 
200
270
  open.value = true;
@@ -208,6 +278,14 @@ watch(selected, (value) => {
208
278
  }
209
279
  });
210
280
 
281
+ watchEffect(() => {
282
+ search.value?.setCustomValidity(required && !disabled && !selected.value ? requiredLabel : "");
283
+
284
+ if (disabled) {
285
+ open.value = false;
286
+ }
287
+ });
288
+
211
289
  watch(open, (value) => {
212
290
  if (value) {
213
291
  highlighted.value = filtered.value.findIndex((option) => option.value === modelValue);
@@ -3,23 +3,45 @@
3
3
  ref="sheet"
4
4
  class="m-0 max-w-none border-0 bg-transparent p-0 backdrop:bg-black/50 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
5
5
  :class="positions[side]"
6
- @close="$emit('update:modelValue', false)"
7
- @click="$event.target === $event.currentTarget && ($event.currentTarget as HTMLDialogElement).close()"
6
+ :aria-labelledby="label && !$slots.header ? (labelId ?? id) : ($attrs as HTMLAttributes)['aria-labelledby']"
7
+ @close="handleClose"
8
+ @cancel="dismiss"
9
+ @click="$event.target === $event.currentTarget && dismiss($event)"
8
10
  >
9
- <Card :label="label" :class="cardClasses[side]">
11
+ <Card
12
+ :label="label"
13
+ :label-id="labelId ?? id"
14
+ :body-padding="bodyPadding"
15
+ :body-gap="bodyGap"
16
+ :class="cardClasses[side]"
17
+ >
18
+ <template v-if="$slots.header" #header>
19
+ <slot name="header" />
20
+ </template>
21
+
10
22
  <template #actions>
23
+ <slot name="actions" />
24
+
11
25
  <Button
12
26
  size="icon"
13
27
  variant="ghost"
14
28
  :icon-prefix="CloseIcon"
15
29
  :aria-label="closeLabel"
16
- @click="sheet?.close()"
30
+ :disabled="!dismissible"
31
+ @click="dismiss"
17
32
  />
18
33
  </template>
19
34
 
20
- <div class="min-h-0 flex-1 overflow-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
35
+ <Stack
36
+ :gap="bodyGap"
37
+ :direction="bodyDirection"
38
+ :align="bodyAlign"
39
+ :justify="bodyJustify"
40
+ :wrap="bodyWrap"
41
+ class="min-h-0 flex-1 overflow-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
42
+ >
21
43
  <slot />
22
- </div>
44
+ </Stack>
23
45
 
24
46
  <template v-if="$slots.footer" #footer>
25
47
  <slot name="footer" />
@@ -29,25 +51,45 @@
29
51
  </template>
30
52
 
31
53
  <script setup lang="ts">
54
+ import { useId, useTemplateRef, type HTMLAttributes } from "vue";
32
55
  import CloseIcon from "~icons/hugeicons/multiplication-sign";
33
- import { useTemplateRef, watchEffect } from "vue";
34
56
  import type { SheetProps, SheetSide } from "../../components/sheet";
57
+ import { useModalDialog } from "../utilities/use-modal-dialog";
35
58
  import Button from "./button.vue";
36
59
  import Card from "./card.vue";
37
- import { useFocusTrap } from "../utilities/use-focus-trap";
38
- import { useScrollLock } from "../utilities/use-scroll-lock";
60
+ import Stack from "./stack.vue";
39
61
 
40
- const { modelValue = false, side = "right", label, closeLabel = "Close" } = defineProps<SheetProps>();
62
+ const {
63
+ modelValue = undefined,
64
+ side = "right",
65
+ label,
66
+ labelId,
67
+ bodyPadding,
68
+ bodyGap = "5",
69
+ bodyDirection,
70
+ bodyAlign,
71
+ bodyJustify,
72
+ bodyWrap,
73
+ closeLabel = "Close",
74
+ dismissible = true,
75
+ } = defineProps<SheetProps>();
41
76
 
42
- defineEmits<{
77
+ const emit = defineEmits<{
43
78
  "update:modelValue": [value: boolean];
44
79
  }>();
45
80
 
46
81
  const sheet = useTemplateRef("sheet");
47
82
 
48
- useFocusTrap(sheet, () => modelValue);
83
+ const id = `sheet-${useId()}`;
49
84
 
50
- useScrollLock(() => modelValue);
85
+ const { open, close, toggle, dismiss, handleClose } = useModalDialog(
86
+ sheet,
87
+ () => modelValue,
88
+ (value) => emit("update:modelValue", value),
89
+ () => dismissible,
90
+ );
91
+
92
+ defineExpose({ open, close, toggle });
51
93
 
52
94
  const positions: Record<SheetSide, string> = {
53
95
  top: "bottom-auto left-0 right-0 top-0 h-auto w-full",
@@ -62,12 +104,4 @@ const cardClasses: Record<SheetSide, string> = {
62
104
  bottom: "max-h-[85vh] w-full border-b-0 border-l-0 border-r-0 shadow-sheet-bottom",
63
105
  left: "h-full w-full border-b-0 border-l-0 border-t-0 shadow-sheet-left",
64
106
  };
65
-
66
- watchEffect(() => {
67
- if (modelValue && !sheet.value?.open) {
68
- sheet.value?.showModal();
69
- } else if (!modelValue && sheet.value?.open) {
70
- sheet.value.close();
71
- }
72
- });
73
107
  </script>