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

Keep selected tab accessible when disabled #4018

Merged
merged 2 commits into from
Aug 9, 2024
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
8 changes: 8 additions & 0 deletions .changeset/4018-tab-selected-accessible-when-disabled.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@ariakit/react-core": patch
"@ariakit/react": patch
---

Accessing selected tabs when disabled

A [Tab](https://ariakit.org/components/tab) component that is both selected and disabled will now remain accessible to keyboard focus even if the [`accessibleWhenDisabled`](https://ariakit.org/reference/tab#accessiblewhendisabled) prop is set to `false`. This ensures users can navigate to other tabs using the keyboard.
1 change: 1 addition & 0 deletions examples/combobox-tabs/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
bg-inherit
text-sm
bottom-0
mt-auto
gap-4
p-2
border-t
Expand Down
34 changes: 34 additions & 0 deletions examples/combobox-tabs/test-browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { query } from "@ariakit/test/playwright";
import { expect, test } from "@playwright/test";

test.beforeEach(async ({ page }) => {
await page.goto("/previews/combobox-tabs", { waitUntil: "networkidle" });
});

test("https://github.com/ariakit/ariakit/issues/3941", async ({ page }) => {
const q = query(page);

await q.combobox().click();
await expect(q.dialog()).toBeVisible();

await page.keyboard.press("ArrowDown");
await page.keyboard.press("ArrowLeft");
await expect(q.tab("Guide 6")).toHaveAttribute("data-active-item");
await expect(q.tab("Guide 6")).toHaveAttribute("data-focus-visible");
await expect(q.tab("Guide 6")).toHaveAttribute("aria-selected", "true");

await page.keyboard.type("ann");
await page.keyboard.press("Backspace");
await expect(q.tab("Guide 0")).toHaveAttribute("data-active-item");
await expect(q.tab("Guide 0")).toHaveAttribute("data-focus-visible");
await expect(q.tab("Guide 0")).toHaveAttribute("aria-selected", "true");

await page.keyboard.press("ArrowRight");
await expect(q.tab("Components 1")).toHaveAttribute("data-active-item");
await expect(q.tab("Components 1")).toHaveAttribute("data-focus-visible");
await expect(q.tab("Components 1")).toHaveAttribute("aria-selected", "true");

await expect(q.tab("Guide 0")).toHaveAttribute("aria-selected", "false");
await expect(q.tab("Guide 0")).not.toHaveAttribute("data-focus-visible");
await expect(q.tab("Guide 0")).not.toHaveAttribute("data-active-item");
});
12 changes: 6 additions & 6 deletions packages/ariakit-react-core/src/focusable/focusable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,9 @@ export const useFocusable = createHook<TagName, FocusableOptions>(
const trulyDisabled = !!disabled && !accessibleWhenDisabled;
const [focusVisible, setFocusVisible] = useState(false);

// When the focusable element is disabled, it doesn't trigger a blur event so
// we can't set focusVisible to false there. Instead, we have to do it here by
// checking the element's disabled attribute.
// When the focusable element is disabled, it doesn't trigger a blur event
// so we can't set focusVisible to false there. Instead, we have to do it
// here by checking the element's disabled attribute.
useEffect(() => {
if (!focusable) return;
if (trulyDisabled && focusVisible) {
Expand Down Expand Up @@ -311,12 +311,12 @@ export const useFocusable = createHook<TagName, FocusableOptions>(
if (!focusable) return;
const element = event.currentTarget;
if (!element) return;
if (!hasFocus(element)) return;
onFocusVisible?.(event);
if (event.defaultPrevented) return;
// Some extensions like 1password dispatches some keydown events on
// autofill and immediately moves focus to the next field. That's why we
// need to check if the current element is still focused.
if (!hasFocus(element)) return;
onFocusVisible?.(event);
if (event.defaultPrevented) return;
setFocusVisible(true);
};

Expand Down
67 changes: 33 additions & 34 deletions packages/ariakit-react-core/src/tab/tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
CompositeItem,
useCompositeItem,
} from "../composite/composite-item.tsx";
import { useEvent, useId, useWrapElement } from "../utils/hooks.ts";
import { useEvent, useId } from "../utils/hooks.ts";
import { useStoreState } from "../utils/store.tsx";
import {
createElement,
Expand Down Expand Up @@ -37,7 +37,6 @@ type HTMLType = HTMLElementTagNameMap[TagName];
*/
export const useTab = createHook<TagName, TabOptions>(function useTab({
store,
accessibleWhenDisabled = true,
getItem: getItemProp,
...props
}) {
Expand Down Expand Up @@ -85,38 +84,7 @@ export const useTab = createHook<TagName, TabOptions>(function useTab({
const selected = store.useState((state) => !!id && state.selectedId === id);
const hasActiveItem = store.useState((state) => !!store.item(state.activeId));
const canRegisterComposedItem = isActive || (selected && !hasActiveItem);

props = useWrapElement(
props,
(element) => {
if (!store?.composite) return element;
const defaultProps = {
id,
store: store.composite,
shouldRegisterItem: canRegisterComposedItem && shouldRegisterItem,
render: element,
} satisfies CompositeItemOptions;
// If the tab is rendered as part of another composite widget such as
// combobox, we need to render it as a composite item. This ensures it's
// recognized in the composite store and lets us manage arrow key
// navigation to move focus to other composite items that might be
// rendered in a tab panel. We only register the selected tab to maintain
// a vertical list orientation.
return (
<CompositeItem
{...defaultProps}
render={
store.combobox && store.composite !== store.combobox ? (
<CompositeItem {...defaultProps} store={store.combobox} />
) : (
element
)
}
/>
);
},
[store, id, canRegisterComposedItem, shouldRegisterItem],
);
const accessibleWhenDisabled = selected || props.accessibleWhenDisabled;

// If the tab is rendered within another composite widget with virtual focus,
// such as combobox, it shouldn't be tabbable even if the tab store uses
Expand All @@ -143,6 +111,37 @@ export const useTab = createHook<TagName, TabOptions>(function useTab({
onClick,
};

// If the tab is rendered as part of another composite widget such as
// combobox, we need to render it as a composite item. This ensures it's
// recognized in the composite store and lets us manage arrow key navigation
// to move focus to other composite items that might be rendered in a tab
// panel. We only register the selected tab to maintain a vertical list
// orientation.
if (store.composite) {
const defaultProps = {
id,
accessibleWhenDisabled,
store: store.composite,
shouldRegisterItem: canRegisterComposedItem && shouldRegisterItem,
render: props.render,
} satisfies CompositeItemOptions;
props = {
...props,
render: (
<CompositeItem
{...defaultProps}
render={
store.combobox && store.composite !== store.combobox ? (
<CompositeItem {...defaultProps} store={store.combobox} />
) : (
defaultProps.render
)
}
/>
),
};
}

props = useCompositeItem({
store,
...props,
Expand Down
Loading