Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: toolbar 23 #314

Merged
merged 5 commits into from
Aug 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions packages/radix-vue/src/RovingFocus/RovingFocusGroup.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<script lang="ts">
export interface RovingFocusGroupProps extends PrimitiveProps {
/**
* The orientation of the group.
* Mainly so arrow navigation is done accordingly (left & right vs. up & down)
*/
orientation?: Orientation;
/**
* The direction of navigation between items.
*/
dir?: Direction;
/**
* Whether keyboard navigation should loop around
* @defaultValue false
*/
loop?: boolean;
currentTabStopId?: string;
defaultCurrentTabStopId?: string;
}

export interface RovingFocusGroupEmits {
(e: "entryFocus", event: Event): void;
}

type RovingContextValue = {
orientation: Ref<Orientation>;
dir: Ref<Direction>;
loop: Ref<boolean>;
currentTabStopId: Ref<string | undefined>;
onItemFocus(tabStopId: string): void;
onItemShiftTab(): void;
onFocusableItemAdd(): void;
onFocusableItemRemove(): void;
};

export const ROVING_FOCUS_INJECTION_KEY =
Symbol() as InjectionKey<RovingContextValue>;
</script>

<script setup lang="ts">
import { ref, type InjectionKey, provide, type Ref, toRefs } from "vue";
import {
type Orientation,
type Direction,
ENTRY_FOCUS,
EVENT_OPTIONS,
} from "./utils";
import { useNewCollection } from "@/shared";
import {
Primitive,
usePrimitiveElement,
type PrimitiveProps,
} from "@/Primitive";
import { focusFirst } from "./utils";
import { useActiveElement } from "@vueuse/core";

const props = withDefaults(defineProps<RovingFocusGroupProps>(), {
loop: false,
dir: "ltr",
orientation: "horizontal",
});
const { loop, orientation, dir } = toRefs(props);
const emits = defineEmits<RovingFocusGroupEmits>();

const currentTabStopId = ref(props.defaultCurrentTabStopId);
const isTabbingBackOut = ref(false);
const isClickFocus = ref(false);
const focusableItemsCount = ref(0);

const activeElement = useActiveElement();
const { primitiveElement, currentElement } = usePrimitiveElement();
const { createCollection } = useNewCollection("rovingFocus");
const collections = createCollection(currentElement);

const handleFocus = (event: FocusEvent) => {
// We normally wouldn't need this check, because we already check
// that the focus is on the current target and not bubbling to it.
// We do this because Safari doesn't focus buttons when clicked, and
// instead, the wrapper will get focused and not through a bubbling event.
const isKeyboardFocus = !isClickFocus.value;

if (
event.currentTarget &&
event.target === event.currentTarget &&
isKeyboardFocus &&
!isTabbingBackOut.value
) {
const entryFocusEvent = new CustomEvent(ENTRY_FOCUS, EVENT_OPTIONS);
event.currentTarget.dispatchEvent(entryFocusEvent);

emits("entryFocus", event);

if (!entryFocusEvent.defaultPrevented) {
const items = collections.value;
const activeItem = items.find((item) => item === activeElement.value);
const currentItem = items.find(
(item) => item.id === currentTabStopId.value
);
const candidateItems = [activeItem, currentItem, ...items].filter(
Boolean
) as typeof items;
focusFirst(candidateItems);
}
}

isClickFocus.value = false;
};

provide(ROVING_FOCUS_INJECTION_KEY, {
loop,
dir,
orientation,
currentTabStopId,
onItemFocus: (tabStopId) => {
currentTabStopId.value = tabStopId;
},
onItemShiftTab: () => {
isTabbingBackOut.value = true;
},
onFocusableItemAdd: () => {
focusableItemsCount.value++;
},
onFocusableItemRemove: () => {
focusableItemsCount.value--;
},
});
</script>

<template>
<Primitive
ref="primitiveElement"
:tabindex="isTabbingBackOut || focusableItemsCount === 0 ? -1 : 0"
:data-orientation="orientation"
:as="as"
:as-child="asChild"
style="outline: none"
@mousedown="isClickFocus = true"
@focus="handleFocus"
@blur="isTabbingBackOut = false"
>
<slot></slot>
</Primitive>
</template>
95 changes: 95 additions & 0 deletions packages/radix-vue/src/RovingFocus/RovingFocusItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { Primitive, type PrimitiveProps } from "@/Primitive";
import { computed, inject, nextTick, onMounted, onUnmounted } from "vue";
import { ROVING_FOCUS_INJECTION_KEY } from "./RovingFocusGroup.vue";
import { useId, useNewCollection } from "@/shared";
import { focusFirst, getFocusIntent, wrapArray } from "./utils";

export interface RovingFocusItemProps extends PrimitiveProps {
tabStopId?: string;
focusable?: boolean;
active?: boolean;
}

const props = withDefaults(defineProps<RovingFocusItemProps>(), {
focusable: true,
active: true,
as: "span",
});

const context = inject(ROVING_FOCUS_INJECTION_KEY);
const autoId = useId();
const id = computed(() => props.tabStopId || autoId);
const isCurrentTabStop = computed(
() => context?.currentTabStopId.value === id.value
);

const { injectCollection } = useNewCollection("rovingFocus");
const collections = injectCollection();

const { onFocusableItemAdd, onFocusableItemRemove } = context!;

onMounted(() => {
if (props.focusable) onFocusableItemAdd();
});
onUnmounted(() => {
if (props.focusable) onFocusableItemRemove();
});

const handleKeydown = (event: KeyboardEvent) => {
if (event.key === "Tab" && event.shiftKey) {
context?.onItemShiftTab();
return;
}

if (event.target !== event.currentTarget) return;

const focusIntent = getFocusIntent(
event,
context?.orientation.value,
context?.dir.value
);

if (focusIntent !== undefined) {
event.preventDefault();
let candidateNodes = [...collections.value];

if (focusIntent === "last") candidateNodes.reverse();
else if (focusIntent === "prev" || focusIntent === "next") {
if (focusIntent === "prev") candidateNodes.reverse();
const currentIndex = candidateNodes.indexOf(
event.currentTarget as HTMLElement
);

candidateNodes = context?.loop.value
? wrapArray(candidateNodes, currentIndex + 1)
: candidateNodes.slice(currentIndex + 1);
}

nextTick(() => focusFirst(candidateNodes));
}
};
</script>

<template>
<Primitive
data-radix-vue-collection-item
:tabindex="isCurrentTabStop ? 0 : -1"
:data-orientation="context?.orientation.value"
:as="as"
:as-child="asChild"
@mousedown="
(event) => {
// 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"
>
<slot></slot>
</Primitive>
</template>
9 changes: 9 additions & 0 deletions packages/radix-vue/src/RovingFocus/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export {
default as RovingFocusGroup,
type RovingFocusGroupProps,
type RovingFocusGroupEmits,
} from "./RovingFocusGroup.vue";
export {
default as RovingFocusItem,
type RovingFocusItemProps,
} from "./RovingFocusItem.vue";
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { ref } from "vue";
import Button from "./_Button.vue";
import ButtonGroup from "./_ButtonGroup.vue";

const dir = ref<"ltr" | "rtl">("ltr");
</script>

<template>
<Story
group="utilities"
title="Roving Focus/Basic"
:layout="{ type: 'grid', width: '50%' }"
auto-props-disabled
>
<Variant title="No orientation (both) + no looping">
<ButtonGroup :dir="dir" defaultValue="two">
<Button value="one">One</Button>
<Button value="two">Two</Button>
<Button disabled value="three"> Three </Button>
<Button value="four">Four</Button>
</ButtonGroup>
</Variant>

<Variant title="No orientation (both) + looping">
<ButtonGroup :dir="dir" defaultValue="two" loop>
<Button value="one">One</Button>
<Button value="two">Two</Button>
<Button disabled value="three"> Three </Button>
<Button value="four">Four</Button>
</ButtonGroup>
</Variant>

<Variant title="Horizontal orientation + no looping">
<ButtonGroup :dir="dir" :orientation="'horizontal'">
<Button value="one">One</Button>
<Button value="two">Two</Button>
<Button disabled value="three"> Three </Button>
<Button value="four">Four</Button>
</ButtonGroup>
</Variant>

<Variant title="Horizontal orientation + looping">
<ButtonGroup :dir="dir" :orientation="'horizontal'" loop>
<Button value="one">One</Button>
<Button value="two">Two</Button>
<Button disabled value="three"> Three </Button>
<Button value="four">Four</Button>
</ButtonGroup>
</Variant>

<Variant title="Vertical orientation + no looping">
<ButtonGroup :dir="dir" :orientation="'vertical'">
<Button value="one">One</Button>
<Button value="two">Two</Button>
<Button disabled value="three"> Three </Button>
<Button value="four">Four</Button>
</ButtonGroup>
</Variant>

<Variant title="Vertical orientation + looping">
<ButtonGroup :dir="dir" :orientation="'vertical'" loop>
<Button value="one">One</Button>
<Button value="two">Two</Button>
<Button disabled value="three"> Three </Button>
<Button value="four">Four</Button>
</ButtonGroup>
</Variant>
</Story>
</template>
35 changes: 35 additions & 0 deletions packages/radix-vue/src/RovingFocus/story/_Button.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { computed, inject, ref } from "vue";
import { RovingFocusItem, type RovingFocusItemProps } from "../";

interface Props extends RovingFocusItemProps {
value: string;
disabled?: boolean;
}
const props = defineProps<Props>();

const context = inject("rovingFocusDemo", {
value: ref(""),
});
const isSelected = computed(() => context.value.value === props.value);
</script>

<template>
<RovingFocusItem asChild :active="isSelected" :focusable="!disabled">
<button
class="border-2 border-blue-600 px-4 py-2 rounded-md"
:class="{ 'bg-gray-900 text-white': isSelected }"
@click="context.value.value = props.value"
@focus="
(event: FocusEvent) => {
if (context.value.value !== undefined) {
(event.target as HTMLElement)?.click();
}
}
"
:disabled="disabled"
>
<slot></slot>
</button>
</RovingFocusItem>
</template>
26 changes: 26 additions & 0 deletions packages/radix-vue/src/RovingFocus/story/_ButtonGroup.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { provide, ref } from "vue";
import { RovingFocusGroup, type RovingFocusGroupProps } from "../";

interface Props extends RovingFocusGroupProps {
defaultValue?: string;
}
const props = defineProps<Props>();

const value = ref(props.defaultValue ?? "");

provide("rovingFocusDemo", {
value,
});
</script>

<template>
<RovingFocusGroup
v-model="value"
v-bind="props"
class="inline-flex gap-4 p-2"
:class="orientation === 'vertical' ? 'flex-col' : 'flex-row'"
>
<slot></slot>
</RovingFocusGroup>
</template>
Loading
Loading