diff --git a/packages/radix-vue/src/RovingFocus/RovingFocusGroup.vue b/packages/radix-vue/src/RovingFocus/RovingFocusGroup.vue
new file mode 100644
index 000000000..2c54eea69
--- /dev/null
+++ b/packages/radix-vue/src/RovingFocus/RovingFocusGroup.vue
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/radix-vue/src/RovingFocus/RovingFocusItem.vue b/packages/radix-vue/src/RovingFocus/RovingFocusItem.vue
new file mode 100644
index 000000000..a14da79d1
--- /dev/null
+++ b/packages/radix-vue/src/RovingFocus/RovingFocusItem.vue
@@ -0,0 +1,95 @@
+
+
+
+ {
+ // We prevent focusing non-focusable items on `mousedown`.
+ // Even though the item has tabIndex={-1}, that only means take it out of the tab order.
+ if (!focusable) event.preventDefault();
+ // Safari doesn't focus a button when clicked so we run our logic on mousedown also
+ else context?.onItemFocus(id);
+ }
+ "
+ @focus="context?.onItemFocus(id)"
+ @keydown="handleKeydown"
+ >
+
+
+
diff --git a/packages/radix-vue/src/RovingFocus/index.ts b/packages/radix-vue/src/RovingFocus/index.ts
new file mode 100644
index 000000000..6272f1381
--- /dev/null
+++ b/packages/radix-vue/src/RovingFocus/index.ts
@@ -0,0 +1,9 @@
+export {
+ default as RovingFocusGroup,
+ type RovingFocusGroupProps,
+ type RovingFocusGroupEmits,
+} from "./RovingFocusGroup.vue";
+export {
+ default as RovingFocusItem,
+ type RovingFocusItemProps,
+} from "./RovingFocusItem.vue";
diff --git a/packages/radix-vue/src/RovingFocus/story/RovingFocusBasic.story.vue b/packages/radix-vue/src/RovingFocus/story/RovingFocusBasic.story.vue
new file mode 100644
index 000000000..8346b65ba
--- /dev/null
+++ b/packages/radix-vue/src/RovingFocus/story/RovingFocusBasic.story.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/radix-vue/src/RovingFocus/story/_Button.vue b/packages/radix-vue/src/RovingFocus/story/_Button.vue
new file mode 100644
index 000000000..afeea5d55
--- /dev/null
+++ b/packages/radix-vue/src/RovingFocus/story/_Button.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
diff --git a/packages/radix-vue/src/RovingFocus/story/_ButtonGroup.vue b/packages/radix-vue/src/RovingFocus/story/_ButtonGroup.vue
new file mode 100644
index 000000000..864517de9
--- /dev/null
+++ b/packages/radix-vue/src/RovingFocus/story/_ButtonGroup.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/packages/radix-vue/src/RovingFocus/utils.ts b/packages/radix-vue/src/RovingFocus/utils.ts
new file mode 100644
index 000000000..a6726f2ca
--- /dev/null
+++ b/packages/radix-vue/src/RovingFocus/utils.ts
@@ -0,0 +1,55 @@
+export type Orientation = "horizontal" | "vertical";
+export type Direction = "ltr" | "rtl";
+
+export const ENTRY_FOCUS = "rovingFocusGroup.onEntryFocus";
+export const EVENT_OPTIONS = { bubbles: false, cancelable: true };
+
+// prettier-ignore
+export const MAP_KEY_TO_FOCUS_INTENT: Record = {
+ ArrowLeft: 'prev', ArrowUp: 'prev',
+ ArrowRight: 'next', ArrowDown: 'next',
+ PageUp: 'first', Home: 'first',
+ PageDown: 'last', End: 'last',
+};
+
+export function getDirectionAwareKey(key: string, dir?: Direction) {
+ if (dir !== "rtl") return key;
+ return key === "ArrowLeft"
+ ? "ArrowRight"
+ : key === "ArrowRight"
+ ? "ArrowLeft"
+ : key;
+}
+
+type FocusIntent = "first" | "last" | "prev" | "next";
+
+export function getFocusIntent(
+ event: KeyboardEvent,
+ orientation?: Orientation,
+ dir?: Direction
+) {
+ const key = getDirectionAwareKey(event.key, dir);
+ if (orientation === "vertical" && ["ArrowLeft", "ArrowRight"].includes(key))
+ return undefined;
+ if (orientation === "horizontal" && ["ArrowUp", "ArrowDown"].includes(key))
+ return undefined;
+ return MAP_KEY_TO_FOCUS_INTENT[key];
+}
+
+export function focusFirst(candidates: HTMLElement[]) {
+ const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement;
+ for (const candidate of candidates) {
+ // if focus is already where we want to go, we don't want to keep going through the candidates
+ if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return;
+ candidate.focus();
+ if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return;
+ }
+}
+
+/**
+ * Wraps an array around itself at a given start index
+ * Example: `wrapArray(['a', 'b', 'c', 'd'], 2) === ['c', 'd', 'a', 'b']`
+ */
+export function wrapArray(array: T[], startIndex: number) {
+ return array.map((_, index) => array[(startIndex + index) % array.length]);
+}
diff --git a/packages/radix-vue/src/Toggle/ToggleRoot.vue b/packages/radix-vue/src/Toggle/ToggleRoot.vue
index 1de3d88a5..6ab6fc967 100644
--- a/packages/radix-vue/src/Toggle/ToggleRoot.vue
+++ b/packages/radix-vue/src/Toggle/ToggleRoot.vue
@@ -55,28 +55,19 @@ const togglePressed = () => {
const dataState = computed(() => {
return pressed.value ? "on" : "off";
});
-
-const handleKeydown = (e: KeyboardEvent) => {
- if (e.key === "Enter") {
- togglePressed();
- }
-};
diff --git a/packages/radix-vue/src/ToggleGroup/ToggleGroup.story.vue b/packages/radix-vue/src/ToggleGroup/ToggleGroup.story.vue
index 1be7010cc..d5c9c02a0 100644
--- a/packages/radix-vue/src/ToggleGroup/ToggleGroup.story.vue
+++ b/packages/radix-vue/src/ToggleGroup/ToggleGroup.story.vue
@@ -3,7 +3,7 @@ import { ToggleGroupItem, ToggleGroupRoot } from "./";
import { Icon } from "@iconify/vue";
import { ref } from "vue";
-const toggleStateSingle = ref();
+const toggleStateSingle = ref("left");
const toggleStateMultiple = ref(["italic"]);
const toggleGroupItemClasses =
@@ -11,9 +11,9 @@ const toggleGroupItemClasses =
-
+
-
+
@@ -58,7 +58,7 @@ const toggleGroupItemClasses =
diff --git a/packages/radix-vue/src/ToggleGroup/ToggleGroupItem.vue b/packages/radix-vue/src/ToggleGroup/ToggleGroupItem.vue
index 6eba95de9..48e412cab 100644
--- a/packages/radix-vue/src/ToggleGroup/ToggleGroupItem.vue
+++ b/packages/radix-vue/src/ToggleGroup/ToggleGroupItem.vue
@@ -1,114 +1,45 @@
-
-
-
+
+
+
+
diff --git a/packages/radix-vue/src/ToggleGroup/ToggleGroupRoot.vue b/packages/radix-vue/src/ToggleGroup/ToggleGroupRoot.vue
index 0d9e0c811..548a2131a 100644
--- a/packages/radix-vue/src/ToggleGroup/ToggleGroupRoot.vue
+++ b/packages/radix-vue/src/ToggleGroup/ToggleGroupRoot.vue
@@ -1,20 +1,20 @@
-
-
-
+
+
+
+
diff --git a/packages/radix-vue/src/Toolbar/Toolbar.story.vue b/packages/radix-vue/src/Toolbar/Toolbar.story.vue
index 122900120..06f8e01b3 100644
--- a/packages/radix-vue/src/Toolbar/Toolbar.story.vue
+++ b/packages/radix-vue/src/Toolbar/Toolbar.story.vue
@@ -15,7 +15,7 @@ const toggleStateMultiple = ref([]);
-
+
-export interface ToolbarButtonProps extends PrimitiveProps {}
-
-
-
-
-
+
+
+
+
+
diff --git a/packages/radix-vue/src/Toolbar/ToolbarLink.vue b/packages/radix-vue/src/Toolbar/ToolbarLink.vue
index 78f4c8d57..bf68836b5 100644
--- a/packages/radix-vue/src/Toolbar/ToolbarLink.vue
+++ b/packages/radix-vue/src/Toolbar/ToolbarLink.vue
@@ -1,43 +1,20 @@
-
-
-
-
-
+
+ {
+ if(event.key === ' ') (event.currentTarget as HTMLElement)?.click()
+ }"
+ >
+
+
+
diff --git a/packages/radix-vue/src/Toolbar/ToolbarRoot.vue b/packages/radix-vue/src/Toolbar/ToolbarRoot.vue
index 385d7e679..4661886c5 100644
--- a/packages/radix-vue/src/Toolbar/ToolbarRoot.vue
+++ b/packages/radix-vue/src/Toolbar/ToolbarRoot.vue
@@ -1,6 +1,6 @@
-
-
-
+
+
+
+
+
diff --git a/packages/radix-vue/src/Toolbar/ToolbarSeparator.vue b/packages/radix-vue/src/Toolbar/ToolbarSeparator.vue
index 4250b6be4..f29c5092a 100644
--- a/packages/radix-vue/src/Toolbar/ToolbarSeparator.vue
+++ b/packages/radix-vue/src/Toolbar/ToolbarSeparator.vue
@@ -1,14 +1,21 @@
-
-
-
-
+
+
+
diff --git a/packages/radix-vue/src/Toolbar/ToolbarToggleGroup.vue b/packages/radix-vue/src/Toolbar/ToolbarToggleGroup.vue
index df667a7cc..5ef0f37f0 100644
--- a/packages/radix-vue/src/Toolbar/ToolbarToggleGroup.vue
+++ b/packages/radix-vue/src/Toolbar/ToolbarToggleGroup.vue
@@ -1,86 +1,31 @@
-
-
-
-
-
+
+
diff --git a/packages/radix-vue/src/Toolbar/ToolbarToggleItem.vue b/packages/radix-vue/src/Toolbar/ToolbarToggleItem.vue
index 12b18e6f2..f77c2e5f9 100644
--- a/packages/radix-vue/src/Toolbar/ToolbarToggleItem.vue
+++ b/packages/radix-vue/src/Toolbar/ToolbarToggleItem.vue
@@ -1,55 +1,15 @@
-
-
-
+
+
+
+
+