# useGridA11y

[← Back to Composables README](https://github.com/NantHealth/featherk/blob/integration/packages/composables/README.md)

Composable that augments a Kendo Vue Grid with accessible keyboard navigation, focus management, and custom behaviors for column header menus (filter/sort).

## Prerequisites

- Vue 3 Composition API
- `@progress/kendo-vue-grid`
- `@featherk/composables`

Install:

```bash
npm install @featherk/composables
```

## Compatibility

- Built and tested against Kendo UI for Vue Grid v6.4.1. Grid usage requires a paid Telerik license.

## Integration Quick Reference

### 1) Minimal import + setup

Place inside a `<script setup lang="ts">` block. Provide a Grid ref and call the composable.

```ts
import { ref, onMounted, watch } from 'vue';
import { useGridA11y } from '@featherk/composables/grid';

const gridRef = ref(null);
const dataResult = ref({ data: [] }); // example data container

const {
  activeFilterButton,
  handleGridKeyDown,
  handleSortChange,
  initA11y // NEW: must be called after Grid + data are present in DOM
} = useGridA11y(gridRef);
```

### 2) Initialize accessibility after Grid mounts and data is available

Call `initA11y()` once the Grid element is in the DOM and the initial (non-empty) data set is ready. If data loads async, watch it. `initA11y()` performs initial attribute setup, internal bookkeeping, and prepares focus targets.

```ts
onMounted(() => {
  // Adjust source as needed for your data state
  watch(
    () => dataResult.value.data,
    (rows) => {
      if (rows && rows.length && gridRef.value) {
        initA11y();        // safe to call again; will no-op after first successful init
      }
    },
    { immediate: true }
  );
});
```

If data can change from empty to non-empty multiple times, the composable guards against redundant full initialization.

### 3) Wire keyboard handler on the Grid

Template snippet showing essential bindings (keep other Grid props as required by your app):

```html
<Grid
  ref="gridRef"
  :dataItems="dataResult.data"
  :dataItemKey="'id'"
  :rowRender="renderRow"            // optional: aria-label for screen reader
  @keydown="handleGridKeyDown"      // keyboard navigation
  @sortchange="handleSortChange"    // composable-aware sort handling
  navigatable="false"               // turn off cell to cell navigation
/>
```

### 4) Provide an accessible row renderer (aria-label)

Not part of `@featherk/composable`, but good practice

Kendo Grid `rowRender` allows you to add an `aria-label` so screen readers announce row contents.

```ts
const renderRow = (h: any, trElement: any, defaultSlots: any, props: any) => {
  const ariaLabel = `Name: ${props.dataItem.name}, Price: ${props.dataItem.price}`;
  const merged = { ...trElement.props, 'aria-label': ariaLabel };
  return h('tr', merged, defaultSlots);
};
```

### 5) Focus the active filter button after filter changes

```ts
import { nextTick } from 'vue';

function onFilterChange(event: any) {
  // update filter state + reload data
  nextTick(() => {
    if (activeFilterButton.value) {
      activeFilterButton.value.focus();
    }
  });
}
```

### 6) Custom sort handling with composable helper

```ts
const optionalCustomSort = (event: any) => {
  loader.value = true;
  setTimeout(() => {
    loader.value = false;
    // apply sort state and reload data
  }, 200);
};

function onSortChange(event: any) {
  handleSortChange(event, optionalCustomSort);
}
```

### 7) Summary checklist

- Import and call `useGridA11y(gridRef)`
- Wait for Grid mount + data, then call `initA11y()`
- Bind returned keyboard handler to Grid `@keydown`
- Bind returned sort handler to Grid `@sortchange` (optionally pass a custom callback)
- Use returned `activeFilterButton` to manage focus after filter updates
- Provide a `rowRender` that adds a descriptive `aria-label` for each row
- Set `navigatable="false"` on the Grid to prefer row-level navigation

## Quick Start

```ts
// <script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import { useGridA11y } from '@featherk/composables/grid';

const gridRef = ref<any>(null);
const dataResult = ref<{ data: any[] }>({ data: [] });

const {
  activeFilterButton,
  handleGridKeyDown,
  handleSortChange,
  initA11y,
} = useGridA11y(gridRef);

onMounted(() => {
  watch(
    () => dataResult.value.data,
    (rows) => {
      if (rows && rows.length && gridRef.value) {
        initA11y(); // safe to call; no-ops after first successful init
      }
    },
    { immediate: true }
  );
});
// </script>
```

Template bindings (essentials shown):

```html
<Grid
  ref="gridRef"
  :dataItems="dataResult.data"
  :dataItemKey="'id'"
  @keydown="handleGridKeyDown"
  @sortchange="handleSortChange"
  navigatable="false" />
```

Optional row renderer for screen readers:

```ts
const renderRow = (h: any, trEl: any, slots: any, props: any) => {
  const ariaLabel = `Name: ${props.dataItem.name}, Price: ${props.dataItem.price}`;
  return h('tr', { ...trEl.props, 'aria-label': ariaLabel }, slots);
};
```

Restore focus after filter changes:

```ts
import { nextTick } from 'vue';

function onFilterChange(event: any) {
  // update filter state + reload data
  nextTick(() => {
    activeFilterButton.value?.focus();
  });
}
```

## API

### `useGridA11y(gridRef: Ref<any>, options?: { focusableMenuSelectors?: string[] })`

Returns helpers to initialize accessibility, manage focus, and handle keyboard and sorting interactions.

- **`activeFilterButton: Ref<HTMLElement | null>`**: Last-activated header filter/menu trigger; used to restore focus after menu closes.
- **`initA11y(): void`**: Initializes attributes, observers, and focus targets once Grid + non-empty data are present.
- **`handleGridKeyDown(e: KeyboardEvent): void`**: Row-level keyboard navigation.
  - Scoped to this grid instance and only runs when the event originates from the grid accessibility/content region (prefers `.k-grid-aria-root`, with header/content fallback selectors for markup variance).
  - ArrowUp/ArrowDown: move between rows (`.k-table-row[data-grid-row-index]`).
  - ArrowLeft/ArrowRight: cycle focus within tabbable elements inside the focused row.
  - Escape: collapse focus back to the row.
  - Safeguards menu triggers to defer to column menu logic.
  - **Important**: Uses a secondary form-control guard so inputs/buttons/Kendo editors keep native keyboard behavior. Exception: when focus is inside a data row, ArrowLeft/ArrowRight are still handled for intra-row focus movement.
- **`handleSortChange(event: any, cb?: (event: any) => void): void`**: Composable-aware sort handling; optionally call a custom callback before applying sort.

### Options

- `options.focusableMenuSelectors?: string[]` — Optional array of CSS selectors used to determine which elements inside a column filter form should be considered focusable for Tab-trapping. If omitted, the composable uses the following built-in defaults targeting common Kendo filter form controls:

  - `.k-filter-menu-container .k-dropdownlist[tabindex='0']`
  - `.k-filter-menu-container input.k-input-inner:not([tabindex='-1']):not([disabled])`
  - `.k-filter-menu-container button:not([tabindex='-1']):not([disabled])`
  - `.k-checkbox`

  Provide this option when your app uses custom widgets or different markup inside filter forms.

Internal behaviors include:

- Column menu keyboard support (Space/Enter activation on triggers; ArrowUp/ArrowDown navigation; Tab trapping in filter forms; Escape returns focus).
- Mutation observers to attach/detach listeners on Kendo popup containers and prune sort options when a column is non-sortable.

## Styling Hook

- Adds `.fk-grid` to the Grid root for FeatherK theming. The composable does not ship CSS; include a FeatherK stylesheet in your app to see visual indicators (e.g., filtered status).

## Accessibility Notes

- Prefer `navigatable="false"` to use row-level navigation.
- Provide descriptive `aria-label`s via `rowRender`.
- Ensure header menu triggers are reachable and announce intent (Filter/Sort).

## Keyboard Event Scope and Form Controls

`handleGridKeyDown` now applies keyboard navigation at a narrower scope. It first verifies the event belongs to the current grid instance's accessibility/content region, then applies form-control checks.

Guard sequence:

1. Resolve event target.
2. Resolve current grid root from `gridRef`.
3. Prefer nearest `.k-grid-aria-root`; continue only when it is contained by this grid instance.
4. Fallback for markup variance: `.k-grid-header`, `.k-grid-content`, `.k-grid-content-locked`.
5. Apply form-control guard (with intra-row ArrowLeft/ArrowRight exception).

This combination prevents toolbar/external interference while preserving intended row/header navigation.

### Using Form Controls in GridToolbar

Form controls in `GridToolbar` are naturally excluded from grid row navigation because they are outside row navigation intent and are filtered by the scope + form-control guards.

### What's Supported

The composable detects and skips grid navigation handling for:

- Native form elements: `<input>`, `<textarea>`, `<select>`, `<button>`
- Kendo form components:
  - `.k-picker-wrap` (DatePicker, TimePicker, etc.)
  - `.k-input-inner` (MaskedTextBox, TextBox, etc.)
  - `.k-textbox` (generic Kendo textbox wrapper)
  - `[role="combobox"]` (dropdowns, autocompletes, etc.)

### Example: GridToolbar Controls (No Keyboard Interference)

This pattern works without extra keydown propagation workarounds:

```html
<Grid
  ref="gridRef"
  :dataItems="gridData"
  @keydown="handleGridKeyDown"
  @sortchange="handleSortChange"
  navigatable="false"
>
  <GridToolbar>
    <!-- These form controls now work naturally -->
    <!-- ArrowLeft/Right move cursor, ArrowUp/Down increment spinners, etc. -->
    <MaskedTextBox
      mask="00/00/0000"
      @keydown="maskedInput.handleKeyDown"
    />
    <DatePicker
      @keydown.capture="datePicker.handleKeyDown"
    />
    <TextBox placeholder="Search..." />
  </GridToolbar>
  <!-- ... grid columns ... -->
</Grid>
```

### How It Works

When a keyboard event is dispatched on the Grid:

1. `handleGridKeyDown` first verifies the target is inside this grid instance's a11y/content scope.
2. It then checks `isFormControl()`.
3. If the target is a form control, the handler exits early and lets the control process keys normally.
4. Exception: ArrowLeft/ArrowRight inside a data row are handled for intra-row focus movement.
5. Otherwise, normal grid row/header navigation logic applies.

This design ensures:

- **Text cursor movement** (ArrowLeft/Right) works in masked inputs
- **Spinner increments** (ArrowUp/Down) work for date/time fields
- **Calendar popups** open with Space and Alt+Down
- **Grid row navigation** still works when focus is on actual grid rows
- **Intra-row focus traversal** still works for ArrowLeft/ArrowRight between focusable elements in the same data row

### Migration from Older Versions

If you have existing code that manually stops propagation on form control events, you can now remove those workarounds:

```ts
// OLD: Manual event handling was required
<MaskedTextBox
  @keydown.stop="maskedInput.handleKeyDown"
/>

// NEW: No additional handling needed; composable handles it automatically
<MaskedTextBox
  @keydown="maskedInput.handleKeyDown"
/>
```

For row action buttons/controls, ArrowLeft and ArrowRight continue to move focus across focusable elements in the current row.

### The isFormControl() Helper

The `handleGridKeyDown` handler uses an internal `isFormControl()` helper function to detect whether an event target should be handled by the grid or left alone. This helper:

- Detects native form control elements by tag name
- Detects Kendo form components by CSS class (`.k-picker-wrap`, `.k-input-inner`, `.k-textbox`, `[role="combobox"]`)
- Returns `true` if the element should manage its own keyboard events
- Allows the grid to skip its navigation logic and let the form control handle the event naturally
- Is intentionally secondary to scope-guarding (the handler first narrows by grid a11y/content region)

### Opting Out

In rare cases where form controls don't match the standard patterns detected by `isFormControl()`, you can add `.stop` to individual event handlers to prevent event bubbling:

```html
<CustomFormComponent
  @keydown.stop="customComponent.handleKeyDown"
/>
```

## Limitations & Assumptions

- Assumes Kendo’s Grid DOM structure and popup containers (`.k-animation-container`).
- Assumes `.k-grid-aria-root` is present in supported Kendo markup; fallback scope selectors are used when markup differs.
- Built for row-level navigation; cell-level `navigatable` conflicts with this model.
- Form control detection relies on standard HTML element names and known Kendo component CSS classes. Custom form components that don't match these patterns may need `.stop` modifier on their event handlers.

## Tips

- Call `initA11y()` when the Grid mounts and data becomes non-empty; subsequent calls are safe.
- Use `activeFilterButton` to manage focus after filter updates.

## Migration Notice

- The runtime file `useGridA11y.ts` currently lives at the package root for compatibility. It may move to `src/grid/useGridA11y.ts` in a future release. When that happens, root exports will be preserved to avoid breaking imports, but consider migrating to subpath imports (e.g., `@featherk/composables/grid`).
