@revojs/ui 0.2.60 → 0.2.62
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/dist/components/accordion.d.ts +13 -0
- package/dist/components/accordion.js +1 -0
- package/dist/components/checkbox.d.ts +1 -0
- package/dist/components/collapsible.d.ts +11 -0
- package/dist/components/collapsible.js +1 -0
- package/dist/components/confirm.d.ts +12 -0
- package/dist/components/confirm.js +1 -0
- package/dist/components/counter.d.ts +5 -0
- package/dist/components/labels.d.ts +24 -0
- package/dist/components/labels.js +1 -0
- package/dist/components/menu.d.ts +34 -0
- package/dist/components/radio-group.d.ts +17 -0
- package/dist/components/radio-group.js +1 -0
- package/dist/components/range.d.ts +2 -0
- package/dist/components/roving-focus.d.ts +7 -0
- package/dist/components/roving-focus.js +1 -0
- package/dist/components/select.d.ts +1 -0
- package/dist/components/stepper.d.ts +16 -0
- package/dist/components/stepper.js +1 -0
- package/dist/components/tabs.d.ts +4 -0
- package/dist/components/tags-input.d.ts +10 -0
- package/dist/components/tags-input.js +1 -0
- package/dist/components/toast.d.ts +19 -0
- package/dist/components/toast.js +1 -0
- package/dist/components/toggle-group.d.ts +5 -2
- package/dist/components/toolbar.d.ts +12 -0
- package/dist/components/toolbar.js +1 -0
- package/dist/components/tooltip.d.ts +1 -0
- package/dist/index.d.ts +15 -5
- package/dist/style.css +4 -0
- package/dist/vue/components/accordion.vue +57 -0
- package/dist/vue/components/checkbox.vue +24 -4
- package/dist/vue/components/collapsible.vue +72 -0
- package/dist/vue/components/collection.vue +5 -2
- package/dist/vue/components/confirm-dialog.vue +45 -0
- package/dist/vue/components/context-menu.vue +178 -0
- package/dist/vue/components/counter.vue +124 -18
- package/dist/vue/components/dialog.vue +5 -2
- package/dist/vue/components/menu-checkbox-item.vue +52 -0
- package/dist/vue/components/menu-group.vue +37 -0
- package/dist/vue/components/menu-layer.vue +152 -0
- package/dist/vue/components/menu-radio-group.vue +36 -0
- package/dist/vue/components/menu-radio-item.vue +50 -0
- package/dist/vue/components/menu.vue +16 -46
- package/dist/vue/components/pagination.vue +26 -6
- package/dist/vue/components/popover.vue +62 -13
- package/dist/vue/components/radio-group.vue +46 -0
- package/dist/vue/components/range.vue +31 -7
- package/dist/vue/components/rating.vue +35 -4
- package/dist/vue/components/select.vue +170 -52
- package/dist/vue/components/sheet.vue +5 -2
- package/dist/vue/components/shell.vue +5 -2
- package/dist/vue/components/stepper.vue +96 -0
- package/dist/vue/components/tabs.vue +37 -43
- package/dist/vue/components/tags-input.vue +141 -0
- package/dist/vue/components/toast-region.vue +233 -0
- package/dist/vue/components/toggle-group.vue +65 -10
- package/dist/vue/components/toolbar.vue +43 -0
- package/dist/vue/components/tooltip.vue +101 -15
- package/dist/vue/components/upload.vue +7 -4
- package/dist/vue/index.d.ts +28 -7
- package/dist/vue/index.js +29 -8
- package/dist/vue/utilities/labels.d.ts +7 -0
- package/dist/vue/utilities/labels.js +62 -0
- package/dist/vue/utilities/menu.d.ts +6 -0
- package/dist/vue/utilities/menu.js +2 -1
- package/dist/vue/utilities/tooltip.d.ts +3 -0
- package/dist/vue/utilities/tooltip.js +4 -0
- package/dist/vue/utilities/use-anchor.d.ts +6 -0
- package/dist/vue/utilities/use-anchor.js +26 -0
- package/dist/vue/utilities/use-confirm.d.ts +24 -0
- package/dist/vue/utilities/use-confirm.js +27 -0
- package/dist/vue/utilities/use-menu-navigation.d.ts +6 -0
- package/dist/vue/utilities/use-menu-navigation.js +48 -0
- package/dist/vue/utilities/use-roving-focus.d.ts +7 -0
- package/dist/vue/utilities/use-roving-focus.js +73 -0
- package/dist/vue/utilities/use-toast.d.ts +27 -0
- package/dist/vue/utilities/use-toast.js +33 -0
- package/dist/vue/utilities/use-typeahead.d.ts +5 -0
- package/dist/vue/utilities/use-typeahead.js +37 -0
- package/package.json +2 -2
- package/dist/vue/utilities/use-focus-trap.d.ts +0 -2
- package/dist/vue/utilities/use-focus-trap.js +0 -34
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Button
|
|
3
|
+
role="menuitemradio"
|
|
4
|
+
tabindex="-1"
|
|
5
|
+
font="normal"
|
|
6
|
+
layout="left"
|
|
7
|
+
variant="ghost"
|
|
8
|
+
:aria-checked="checked"
|
|
9
|
+
:data-state="checked ? 'checked' : 'unchecked'"
|
|
10
|
+
:icon-prefix="iconPrefix"
|
|
11
|
+
:icon-suffix="checked ? TickIcon : undefined"
|
|
12
|
+
:size="size ?? menuSize ?? 'md'"
|
|
13
|
+
:disabled="disabled"
|
|
14
|
+
class="w-full"
|
|
15
|
+
@click="select"
|
|
16
|
+
>
|
|
17
|
+
<slot>
|
|
18
|
+
<span v-if="label" class="flex-1 truncate px-1">{{ label }}</span>
|
|
19
|
+
</slot>
|
|
20
|
+
</Button>
|
|
21
|
+
</template>
|
|
22
|
+
|
|
23
|
+
<script setup lang="ts" generic="Value extends MenuRadioValue = MenuRadioValue">
|
|
24
|
+
import TickIcon from "~icons/hugeicons/tick-02";
|
|
25
|
+
import { computed, inject } from "vue";
|
|
26
|
+
import type { MenuRadioItemProps, MenuRadioValue } from "../../components/menu";
|
|
27
|
+
import { menuCloseKey, menuRadioKey, menuSizeKey } from "../utilities/menu";
|
|
28
|
+
import Button from "./button.vue";
|
|
29
|
+
|
|
30
|
+
const { value, label, iconPrefix, size = undefined, disabled = false } = defineProps<MenuRadioItemProps<Value>>();
|
|
31
|
+
|
|
32
|
+
const emit = defineEmits<{ select: [event: MouseEvent] }>();
|
|
33
|
+
|
|
34
|
+
const close = inject(menuCloseKey, undefined);
|
|
35
|
+
|
|
36
|
+
const menuSize = inject(menuSizeKey, undefined);
|
|
37
|
+
|
|
38
|
+
const group = inject(menuRadioKey, undefined);
|
|
39
|
+
|
|
40
|
+
const checked = computed(() => group?.value.value === value);
|
|
41
|
+
|
|
42
|
+
function select(event: MouseEvent) {
|
|
43
|
+
emit("select", event);
|
|
44
|
+
|
|
45
|
+
if (!event.defaultPrevented) {
|
|
46
|
+
group?.select(value);
|
|
47
|
+
close?.();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
</script>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
ref="popover"
|
|
4
4
|
v-model="isOpen"
|
|
5
5
|
content-role="menu"
|
|
6
|
-
:trigger-label="triggerLabel"
|
|
6
|
+
:trigger-label="triggerLabel ?? labels.menu"
|
|
7
7
|
:trigger-class="triggerClass"
|
|
8
8
|
:trigger-layout="triggerLayout"
|
|
9
9
|
:trigger-variant="triggerVariant"
|
|
@@ -31,11 +31,13 @@
|
|
|
31
31
|
import { computed, nextTick, provide, ref, useTemplateRef, watch } from "vue";
|
|
32
32
|
import type { MenuProps } from "../../components/menu";
|
|
33
33
|
import { menuCloseKey, menuSizeKey } from "../utilities/menu";
|
|
34
|
+
import { useUiLabels } from "../utilities/labels";
|
|
35
|
+
import { useMenuNavigation } from "../utilities/use-menu-navigation";
|
|
34
36
|
import Popover from "./popover.vue";
|
|
35
37
|
|
|
36
38
|
const {
|
|
37
39
|
modelValue = false,
|
|
38
|
-
triggerLabel
|
|
40
|
+
triggerLabel,
|
|
39
41
|
triggerClass,
|
|
40
42
|
triggerLayout = "center",
|
|
41
43
|
triggerVariant = "ghost",
|
|
@@ -54,25 +56,20 @@ const emit = defineEmits<{
|
|
|
54
56
|
"update:modelValue": [value: boolean];
|
|
55
57
|
}>();
|
|
56
58
|
|
|
59
|
+
const labels = useUiLabels();
|
|
60
|
+
|
|
57
61
|
const popover = useTemplateRef("popover");
|
|
58
62
|
|
|
63
|
+
const { focusItem, handleKeyDown: navigate } = useMenuNavigation();
|
|
64
|
+
|
|
59
65
|
const isOpen = ref(modelValue);
|
|
60
66
|
|
|
61
67
|
let initialFocus: "first" | "last" = "first";
|
|
62
68
|
|
|
63
|
-
function
|
|
69
|
+
function menu() {
|
|
64
70
|
const element: HTMLElement | undefined = popover.value?.$el;
|
|
65
71
|
|
|
66
|
-
return [
|
|
67
|
-
...(element?.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]:not([aria-disabled="true"])') ??
|
|
68
|
-
[]),
|
|
69
|
-
];
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function focusItem(index: number) {
|
|
73
|
-
const list = items();
|
|
74
|
-
|
|
75
|
-
list[(index + list.length) % list.length]?.focus();
|
|
72
|
+
return element?.querySelector("[role='menu']");
|
|
76
73
|
}
|
|
77
74
|
|
|
78
75
|
function close() {
|
|
@@ -82,13 +79,13 @@ function close() {
|
|
|
82
79
|
function handleKeyDown(event: KeyboardEvent) {
|
|
83
80
|
const target = event.target as HTMLElement;
|
|
84
81
|
|
|
85
|
-
if (target.getAttribute("aria-haspopup") === "menu") {
|
|
82
|
+
if (target.getAttribute("aria-haspopup") === "menu" && !target.closest("[role='menu']")) {
|
|
86
83
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
87
84
|
event.preventDefault();
|
|
88
85
|
initialFocus = event.key === "ArrowDown" ? "first" : "last";
|
|
89
86
|
|
|
90
87
|
if (isOpen.value) {
|
|
91
|
-
focusItem(initialFocus === "first" ? 0 : -1);
|
|
88
|
+
focusItem(menu(), initialFocus === "first" ? 0 : -1);
|
|
92
89
|
} else {
|
|
93
90
|
isOpen.value = true;
|
|
94
91
|
}
|
|
@@ -97,40 +94,13 @@ function handleKeyDown(event: KeyboardEvent) {
|
|
|
97
94
|
return;
|
|
98
95
|
}
|
|
99
96
|
|
|
100
|
-
|
|
101
|
-
|
|
97
|
+
if (event.key === "Tab") {
|
|
98
|
+
close();
|
|
102
99
|
|
|
103
|
-
if (index === -1) {
|
|
104
100
|
return;
|
|
105
101
|
}
|
|
106
102
|
|
|
107
|
-
|
|
108
|
-
event.preventDefault();
|
|
109
|
-
focusItem(index + 1);
|
|
110
|
-
} else if (event.key === "ArrowUp") {
|
|
111
|
-
event.preventDefault();
|
|
112
|
-
focusItem(index - 1);
|
|
113
|
-
} else if (event.key === "Home") {
|
|
114
|
-
event.preventDefault();
|
|
115
|
-
focusItem(0);
|
|
116
|
-
} else if (event.key === "End") {
|
|
117
|
-
event.preventDefault();
|
|
118
|
-
focusItem(-1);
|
|
119
|
-
} else if (event.key === "Tab") {
|
|
120
|
-
close();
|
|
121
|
-
} else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
122
|
-
const character = event.key.toLowerCase();
|
|
123
|
-
|
|
124
|
-
for (let offset = 1; offset <= list.length; offset++) {
|
|
125
|
-
const item = list[(index + offset) % list.length];
|
|
126
|
-
|
|
127
|
-
if (item.textContent?.trim().toLowerCase().startsWith(character)) {
|
|
128
|
-
event.preventDefault();
|
|
129
|
-
item.focus();
|
|
130
|
-
break;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
103
|
+
navigate(event);
|
|
134
104
|
}
|
|
135
105
|
|
|
136
106
|
watch(
|
|
@@ -145,7 +115,7 @@ watch(isOpen, async (value) => {
|
|
|
145
115
|
|
|
146
116
|
if (value) {
|
|
147
117
|
await nextTick();
|
|
148
|
-
focusItem(initialFocus === "first" ? 0 : -1);
|
|
118
|
+
focusItem(menu(), initialFocus === "first" ? 0 : -1);
|
|
149
119
|
initialFocus = "first";
|
|
150
120
|
}
|
|
151
121
|
});
|
|
@@ -1,13 +1,31 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<Stack
|
|
3
|
-
|
|
2
|
+
<Stack
|
|
3
|
+
is="nav"
|
|
4
|
+
:aria-label="label ?? labels.pagination"
|
|
5
|
+
direction="horizontal"
|
|
6
|
+
justify="end"
|
|
7
|
+
gap="1"
|
|
8
|
+
:aria-busy="loading || undefined"
|
|
9
|
+
>
|
|
10
|
+
<Button
|
|
11
|
+
:label="previousLabel ?? labels.previous"
|
|
12
|
+
variant="ghost"
|
|
13
|
+
:disabled="!hasPrevious || loading"
|
|
14
|
+
@click="$emit('previous')"
|
|
15
|
+
/>
|
|
4
16
|
|
|
5
|
-
<Button
|
|
17
|
+
<Button
|
|
18
|
+
:label="nextLabel ?? labels.next"
|
|
19
|
+
variant="ghost"
|
|
20
|
+
:disabled="!hasNext || loading"
|
|
21
|
+
@click="$emit('next')"
|
|
22
|
+
/>
|
|
6
23
|
</Stack>
|
|
7
24
|
</template>
|
|
8
25
|
|
|
9
26
|
<script setup lang="ts">
|
|
10
27
|
import type { PaginationProps } from "../../components/pagination";
|
|
28
|
+
import { useUiLabels } from "../utilities/labels";
|
|
11
29
|
import Button from "./button.vue";
|
|
12
30
|
import Stack from "./stack.vue";
|
|
13
31
|
|
|
@@ -15,10 +33,12 @@ const {
|
|
|
15
33
|
hasPrevious = false,
|
|
16
34
|
hasNext = false,
|
|
17
35
|
loading = false,
|
|
18
|
-
previousLabel
|
|
19
|
-
nextLabel
|
|
20
|
-
label
|
|
36
|
+
previousLabel,
|
|
37
|
+
nextLabel,
|
|
38
|
+
label,
|
|
21
39
|
} = defineProps<PaginationProps>();
|
|
22
40
|
|
|
23
41
|
defineEmits<{ previous: []; next: [] }>();
|
|
42
|
+
|
|
43
|
+
const labels = useUiLabels();
|
|
24
44
|
</script>
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
ref="container"
|
|
4
4
|
class="relative"
|
|
5
5
|
:class="matchTriggerWidth ? 'w-full' : 'w-fit'"
|
|
6
|
+
:style="anchorStyle"
|
|
6
7
|
@keydown="handleKeyDown"
|
|
7
|
-
@focusout="$event.relatedTarget && !container?.contains($event.relatedTarget as Node) &&
|
|
8
|
+
@focusout="$event.relatedTarget && !container?.contains($event.relatedTarget as Node) && setOpen(false)"
|
|
8
9
|
>
|
|
9
10
|
<Button
|
|
10
11
|
:id="triggerId"
|
|
@@ -16,11 +17,12 @@
|
|
|
16
17
|
:aria-haspopup="contentRole"
|
|
17
18
|
:aria-expanded="isOpen"
|
|
18
19
|
:aria-controls="isOpen ? contentId : undefined"
|
|
20
|
+
:data-state="isOpen ? 'open' : 'closed'"
|
|
19
21
|
@click="toggle"
|
|
20
22
|
>
|
|
21
23
|
<slot name="trigger" :open="open" :close="close" :toggle="toggle">
|
|
22
24
|
<span class="truncate px-1" :class="triggerLayout === 'center' ? '' : 'flex-1'">{{
|
|
23
|
-
triggerLabel
|
|
25
|
+
triggerLabel ?? labels.open
|
|
24
26
|
}}</span>
|
|
25
27
|
</slot>
|
|
26
28
|
</Button>
|
|
@@ -28,16 +30,27 @@
|
|
|
28
30
|
<Stack
|
|
29
31
|
v-if="isOpen"
|
|
30
32
|
:id="contentId"
|
|
33
|
+
ref="content"
|
|
31
34
|
:role="contentRole"
|
|
32
35
|
:aria-labelledby="triggerId"
|
|
36
|
+
:popover="anchored ? 'manual' : undefined"
|
|
37
|
+
:style="anchored ? layerStyle : undefined"
|
|
38
|
+
:tabindex="contentRole === 'dialog' ? -1 : undefined"
|
|
33
39
|
:padding="contentPadding"
|
|
34
40
|
:gap="contentGap"
|
|
35
41
|
:direction="contentDirection"
|
|
36
42
|
:align="contentAlign"
|
|
37
43
|
:justify="contentJustify"
|
|
38
44
|
:wrap="contentWrap"
|
|
39
|
-
|
|
40
|
-
|
|
45
|
+
data-state="open"
|
|
46
|
+
class="max-w-xs rounded-ui border border-border bg-popover text-popover-foreground shadow-xl outline-none"
|
|
47
|
+
:class="[
|
|
48
|
+
anchored
|
|
49
|
+
? `inset-auto mx-0 my-1 overflow-visible [position-try-fallbacks:flip-block,flip-inline,flip-block_flip-inline] ${areas[`${placement}-${align}`]}`
|
|
50
|
+
: `absolute z-10 ${placements[placement]} ${aligns[align]}`,
|
|
51
|
+
contentClass,
|
|
52
|
+
matchTriggerWidth ? (anchored ? '[width:anchor-size(width)]' : 'w-full') : '',
|
|
53
|
+
]"
|
|
41
54
|
>
|
|
42
55
|
<slot :open="open" :close="close" :toggle="toggle" />
|
|
43
56
|
</Stack>
|
|
@@ -45,14 +58,16 @@
|
|
|
45
58
|
</template>
|
|
46
59
|
|
|
47
60
|
<script setup lang="ts">
|
|
48
|
-
import { onUnmounted, ref, useId, useTemplateRef, watch } from "vue";
|
|
61
|
+
import { computed, nextTick, onUnmounted, ref, useId, useTemplateRef, watch } from "vue";
|
|
49
62
|
import type { PopoverAlign, PopoverPlacement, PopoverProps } from "../../components/popover";
|
|
63
|
+
import { useUiLabels } from "../utilities/labels";
|
|
64
|
+
import { useAnchor } from "../utilities/use-anchor";
|
|
50
65
|
import Button from "./button.vue";
|
|
51
66
|
import Stack from "./stack.vue";
|
|
52
67
|
|
|
53
68
|
const {
|
|
54
69
|
modelValue = false,
|
|
55
|
-
triggerLabel
|
|
70
|
+
triggerLabel,
|
|
56
71
|
triggerClass,
|
|
57
72
|
triggerLayout = "center",
|
|
58
73
|
triggerVariant = "outline",
|
|
@@ -75,8 +90,16 @@ const emit = defineEmits<{
|
|
|
75
90
|
"update:modelValue": [value: boolean];
|
|
76
91
|
}>();
|
|
77
92
|
|
|
93
|
+
const labels = useUiLabels();
|
|
94
|
+
|
|
78
95
|
const container = useTemplateRef("container");
|
|
79
96
|
|
|
97
|
+
const content = useTemplateRef<InstanceType<typeof Stack>>("content");
|
|
98
|
+
|
|
99
|
+
const contentElement = computed<HTMLElement | null>(() => content.value?.$el ?? null);
|
|
100
|
+
|
|
101
|
+
const { anchored, anchorStyle, layerStyle } = useAnchor(contentElement);
|
|
102
|
+
|
|
80
103
|
const triggerId = useId();
|
|
81
104
|
|
|
82
105
|
const contentId = useId();
|
|
@@ -87,8 +110,15 @@ const placements: Record<PopoverPlacement, string> = {
|
|
|
87
110
|
};
|
|
88
111
|
|
|
89
112
|
const aligns: Record<PopoverAlign, string> = {
|
|
90
|
-
start: "
|
|
91
|
-
end: "
|
|
113
|
+
start: "start-0",
|
|
114
|
+
end: "end-0",
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const areas: Record<`${PopoverPlacement}-${PopoverAlign}`, string> = {
|
|
118
|
+
"bottom-start": "[position-area:block-end_span-inline-end]",
|
|
119
|
+
"bottom-end": "[position-area:block-end_span-inline-start]",
|
|
120
|
+
"top-start": "[position-area:block-start_span-inline-end]",
|
|
121
|
+
"top-end": "[position-area:block-start_span-inline-start]",
|
|
92
122
|
};
|
|
93
123
|
|
|
94
124
|
const isOpen = ref(modelValue);
|
|
@@ -103,17 +133,28 @@ function open() {
|
|
|
103
133
|
setOpen(true);
|
|
104
134
|
}
|
|
105
135
|
|
|
106
|
-
function close() {
|
|
136
|
+
async function close() {
|
|
137
|
+
const restore = contentElement.value?.contains(document.activeElement) ?? false;
|
|
138
|
+
|
|
107
139
|
setOpen(false);
|
|
140
|
+
|
|
141
|
+
if (restore) {
|
|
142
|
+
await nextTick();
|
|
143
|
+
document.getElementById(triggerId)?.focus();
|
|
144
|
+
}
|
|
108
145
|
}
|
|
109
146
|
|
|
110
147
|
function toggle() {
|
|
111
|
-
|
|
148
|
+
if (isOpen.value) {
|
|
149
|
+
close();
|
|
150
|
+
} else {
|
|
151
|
+
open();
|
|
152
|
+
}
|
|
112
153
|
}
|
|
113
154
|
|
|
114
155
|
function handlePointerDown(event: PointerEvent) {
|
|
115
156
|
if (!container.value?.contains(event.target as Node)) {
|
|
116
|
-
|
|
157
|
+
setOpen(false);
|
|
117
158
|
}
|
|
118
159
|
}
|
|
119
160
|
|
|
@@ -121,20 +162,28 @@ function handleKeyDown(event: KeyboardEvent) {
|
|
|
121
162
|
if (event.key === "Escape" && isOpen.value) {
|
|
122
163
|
event.preventDefault();
|
|
123
164
|
event.stopPropagation();
|
|
124
|
-
|
|
165
|
+
setOpen(false);
|
|
125
166
|
document.getElementById(triggerId)?.focus();
|
|
126
167
|
}
|
|
127
168
|
}
|
|
128
169
|
|
|
129
170
|
watch(
|
|
130
171
|
isOpen,
|
|
131
|
-
(value) => {
|
|
172
|
+
async (value) => {
|
|
132
173
|
if (typeof document === "undefined") {
|
|
133
174
|
return;
|
|
134
175
|
}
|
|
135
176
|
|
|
136
177
|
if (value) {
|
|
137
178
|
document.addEventListener("pointerdown", handlePointerDown);
|
|
179
|
+
|
|
180
|
+
if (contentRole === "dialog") {
|
|
181
|
+
await nextTick();
|
|
182
|
+
|
|
183
|
+
const element = contentElement.value;
|
|
184
|
+
|
|
185
|
+
(element?.querySelector<HTMLElement>("[autofocus]") ?? element)?.focus({ preventScroll: true });
|
|
186
|
+
}
|
|
138
187
|
} else {
|
|
139
188
|
document.removeEventListener("pointerdown", handlePointerDown);
|
|
140
189
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<fieldset class="m-0 flex min-w-0 flex-col gap-3 border-0 p-0" :disabled="disabled">
|
|
3
|
+
<legend v-if="label" class="p-0 text-sm font-normal">
|
|
4
|
+
{{ label }}<span v-if="required" aria-hidden="true" class="text-destructive"> *</span>
|
|
5
|
+
</legend>
|
|
6
|
+
|
|
7
|
+
<Stack :direction="orientation" :gap="orientation === 'horizontal' ? '5' : '3'" wrap>
|
|
8
|
+
<Radio
|
|
9
|
+
v-for="option in options"
|
|
10
|
+
:key="option.value"
|
|
11
|
+
:model-value="modelValue"
|
|
12
|
+
:value="option.value"
|
|
13
|
+
:label="option.label"
|
|
14
|
+
:description="option.description"
|
|
15
|
+
:name="name ?? id"
|
|
16
|
+
:required="required"
|
|
17
|
+
:disabled="disabled || option.disabled"
|
|
18
|
+
@update:model-value="$emit('update:modelValue', $event as Value)"
|
|
19
|
+
/>
|
|
20
|
+
</Stack>
|
|
21
|
+
</fieldset>
|
|
22
|
+
</template>
|
|
23
|
+
|
|
24
|
+
<script setup lang="ts" generic="Value extends RadioValue = RadioValue">
|
|
25
|
+
import { useId } from "vue";
|
|
26
|
+
import type { RadioValue } from "../../components/radio";
|
|
27
|
+
import type { RadioGroupProps } from "../../components/radio-group";
|
|
28
|
+
import Radio from "./radio.vue";
|
|
29
|
+
import Stack from "./stack.vue";
|
|
30
|
+
|
|
31
|
+
const {
|
|
32
|
+
modelValue,
|
|
33
|
+
options,
|
|
34
|
+
label,
|
|
35
|
+
name,
|
|
36
|
+
orientation = "vertical",
|
|
37
|
+
required = false,
|
|
38
|
+
disabled = false,
|
|
39
|
+
} = defineProps<RadioGroupProps<Value>>();
|
|
40
|
+
|
|
41
|
+
defineEmits<{
|
|
42
|
+
"update:modelValue": [value: Value];
|
|
43
|
+
}>();
|
|
44
|
+
|
|
45
|
+
const id = useId();
|
|
46
|
+
</script>
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<Stack :class="interactionVariants[interaction]">
|
|
3
3
|
<Stack v-if="label" direction="horizontal" align="center" justify="between" gap="1" class="text-sm">
|
|
4
|
-
<span class="font-normal">{{ label }}</span>
|
|
4
|
+
<span :id="labelId" class="font-normal">{{ label }}</span>
|
|
5
5
|
|
|
6
6
|
<output class="tabular-nums opacity-60">
|
|
7
|
-
{{ modelValue }}
|
|
7
|
+
{{ valueText ? valueText(modelValue) : modelValue }}
|
|
8
8
|
</output>
|
|
9
9
|
</Stack>
|
|
10
10
|
|
|
@@ -17,8 +17,11 @@
|
|
|
17
17
|
:aria-valuemin="min"
|
|
18
18
|
:aria-valuemax="max"
|
|
19
19
|
:aria-valuenow="modelValue"
|
|
20
|
-
:aria-
|
|
20
|
+
:aria-valuetext="valueText ? valueText(modelValue) : undefined"
|
|
21
|
+
:aria-labelledby="label ? labelId : undefined"
|
|
22
|
+
aria-orientation="horizontal"
|
|
21
23
|
:aria-disabled="disabled"
|
|
24
|
+
:data-disabled="disabled ? '' : undefined"
|
|
22
25
|
@pointerdown="onPointerDown"
|
|
23
26
|
@pointermove="onPointerMove"
|
|
24
27
|
@pointerup="onPointerUp"
|
|
@@ -36,12 +39,21 @@
|
|
|
36
39
|
</template>
|
|
37
40
|
|
|
38
41
|
<script setup lang="ts">
|
|
39
|
-
import { computed, useTemplateRef } from "vue";
|
|
42
|
+
import { computed, useId, useTemplateRef } from "vue";
|
|
40
43
|
import type { ControlInteraction } from "../../components/control.ts";
|
|
41
44
|
import type { RangeProps } from "../../components/range";
|
|
42
45
|
import Stack from "./stack.vue";
|
|
43
46
|
|
|
44
|
-
const {
|
|
47
|
+
const {
|
|
48
|
+
modelValue = 0,
|
|
49
|
+
label,
|
|
50
|
+
min = 0,
|
|
51
|
+
max = 100,
|
|
52
|
+
step = 1,
|
|
53
|
+
largeStep,
|
|
54
|
+
valueText,
|
|
55
|
+
disabled = false,
|
|
56
|
+
} = defineProps<RangeProps>();
|
|
45
57
|
|
|
46
58
|
const emit = defineEmits<{
|
|
47
59
|
"update:modelValue": [value: number];
|
|
@@ -56,6 +68,10 @@ const interactionVariants: Record<ControlInteraction, string> = {
|
|
|
56
68
|
|
|
57
69
|
const track = useTemplateRef("track");
|
|
58
70
|
|
|
71
|
+
const labelId = useId();
|
|
72
|
+
|
|
73
|
+
const jump = computed(() => largeStep ?? Math.max(step, (max - min) / 10));
|
|
74
|
+
|
|
59
75
|
const percent = computed(() => (max > min ? Math.min(100, Math.max(0, ((modelValue - min) / (max - min)) * 100)) : 0));
|
|
60
76
|
|
|
61
77
|
let dragging = false;
|
|
@@ -108,11 +124,19 @@ function onKeyDown(event: KeyboardEvent) {
|
|
|
108
124
|
return;
|
|
109
125
|
}
|
|
110
126
|
|
|
127
|
+
const amount = event.shiftKey ? jump.value : step;
|
|
128
|
+
|
|
111
129
|
if (event.key === "ArrowRight" || event.key === "ArrowUp") {
|
|
112
|
-
commit(modelValue +
|
|
130
|
+
commit(modelValue + amount);
|
|
113
131
|
event.preventDefault();
|
|
114
132
|
} else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
|
|
115
|
-
commit(modelValue -
|
|
133
|
+
commit(modelValue - amount);
|
|
134
|
+
event.preventDefault();
|
|
135
|
+
} else if (event.key === "PageUp") {
|
|
136
|
+
commit(modelValue + jump.value);
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
} else if (event.key === "PageDown") {
|
|
139
|
+
commit(modelValue - jump.value);
|
|
116
140
|
event.preventDefault();
|
|
117
141
|
} else if (event.key === "Home") {
|
|
118
142
|
commit(min);
|
|
@@ -2,7 +2,16 @@
|
|
|
2
2
|
<Stack>
|
|
3
3
|
<span v-if="label" class="text-sm font-normal">{{ label }}</span>
|
|
4
4
|
|
|
5
|
-
<Stack
|
|
5
|
+
<Stack
|
|
6
|
+
ref="group"
|
|
7
|
+
direction="horizontal"
|
|
8
|
+
align="center"
|
|
9
|
+
gap="0"
|
|
10
|
+
role="radiogroup"
|
|
11
|
+
:aria-label="label"
|
|
12
|
+
@keydown="handleKeyDown"
|
|
13
|
+
@focusin="handleFocusIn"
|
|
14
|
+
>
|
|
6
15
|
<Button
|
|
7
16
|
v-for="value in max"
|
|
8
17
|
:key="value"
|
|
@@ -11,9 +20,10 @@
|
|
|
11
20
|
variant="ghost"
|
|
12
21
|
role="radio"
|
|
13
22
|
:aria-checked="value === modelValue"
|
|
14
|
-
:aria-label="
|
|
23
|
+
:aria-label="labels.rating(value, max)"
|
|
24
|
+
:data-value="value"
|
|
15
25
|
:disabled="readonly || disabled"
|
|
16
|
-
@click="
|
|
26
|
+
@click="select(value)"
|
|
17
27
|
>
|
|
18
28
|
<Icon :is="StarIcon" :size="20" :class="{ '[&_path]:fill-current': value <= modelValue }" />
|
|
19
29
|
</Button>
|
|
@@ -23,6 +33,9 @@
|
|
|
23
33
|
|
|
24
34
|
<script setup lang="ts">
|
|
25
35
|
import StarIcon from "~icons/hugeicons/star";
|
|
36
|
+
import { computed, useTemplateRef } from "vue";
|
|
37
|
+
import { useUiLabels } from "../utilities/labels";
|
|
38
|
+
import { useRovingFocus } from "../utilities/use-roving-focus";
|
|
26
39
|
import Button from "./button.vue";
|
|
27
40
|
import Icon from "./icon.vue";
|
|
28
41
|
import Stack from "./stack.vue";
|
|
@@ -30,10 +43,28 @@ import type { RatingProps, RatingState } from "../../components/rating";
|
|
|
30
43
|
|
|
31
44
|
const { modelValue = 0, max = 5, label, readonly = false, disabled = false } = defineProps<RatingProps>();
|
|
32
45
|
|
|
33
|
-
defineEmits<{
|
|
46
|
+
const emit = defineEmits<{
|
|
34
47
|
"update:modelValue": [value: number];
|
|
35
48
|
}>();
|
|
36
49
|
|
|
50
|
+
const labels = useUiLabels();
|
|
51
|
+
|
|
52
|
+
const group = useTemplateRef<InstanceType<typeof Stack>>("group");
|
|
53
|
+
|
|
54
|
+
const { handleKeyDown, handleFocusIn } = useRovingFocus(
|
|
55
|
+
computed(() => group.value?.$el ?? null),
|
|
56
|
+
() => ({
|
|
57
|
+
selector: "[role='radio']",
|
|
58
|
+
onMove: (element: HTMLElement) => select(Number(element.dataset.value)),
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
function select(value: number) {
|
|
63
|
+
if (!readonly && !disabled) {
|
|
64
|
+
emit("update:modelValue", value);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
37
68
|
function state(value: number): RatingState {
|
|
38
69
|
return value <= modelValue ? "active" : "inactive";
|
|
39
70
|
}
|