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

Improve "subgrid" support in Composite #3170

Open
andrewhayward opened this issue Dec 8, 2023 · 4 comments
Open

Improve "subgrid" support in Composite #3170

andrewhayward opened this issue Dec 8, 2023 · 4 comments
Labels

Comments

@andrewhayward
Copy link

Motivation

With traditional tables, it is possible to create somewhat complex arrangements, such that cells occupy more than one row or column, using rowspan and colspan.

A table, with three rows and three columns. The central cell contains four subcells, in two columns and two rows.
<table>
  <tbody>
    <tr>
      <td>A1</td>
      <td colspan="2">A2</td>
      <td>A3</td>
    </tr>
    <tr>
      <td rowspan="2">B1</td>
      <td>B2.aa</td>
      <td>B2.ab</td>
      <td rowspan="2">B3</td>
    </tr>
    <tr>
      <td>B2.ba</td>
      <td>B2.bb</td>
    </tr>
    <tr>
      <td>C1</td>
      <td colspan="2">C2</td>
      <td>C3</td>
    </tr>
  </tbody>
</table>

It is not currently possible to add Composite support to such arrangements, where directional navigation behaves in an expected manner.

Minimal expected behaviour Actually achievable behaviour
The same tabular layout as above, with directional arrows between cells showing expected navigational movement. The movement is logically vertical and horizontal. The same tabular layout as above, with directional arrows between cells showing actual navigational movement. The movement is not direct or logical.

While setting focusShift to true in the store configuration resolves some of the navigation issues, it adds them in other places, so is not a viable solution.

Usage example

This could potentially be done in a couple of ways.

The first would be to mirror how HTML tables work, with rowSpan and colSpan props:

<Composite store={ store }>
  <CompositeRow>
    <CompositeItem>A1</CompositeItem>
    <CompositeItem colSpan={ 2 }>A2</CompositeItem>
    <CompositeItem>A3</CompositeItem>
  </CompositeRow>
  <CompositeRow>
    <CompositeItem rowSpan={ 2 }>B1</CompositeItem>
    <CompositeItem>B2.aa</CompositeItem>
    <CompositeItem>B2.ab</CompositeItem>
    <CompositeItem rowSpan={ 2 }>B3</CompositeItem>
  </CompositeRow>
  <CompositeRow>
    <CompositeItem>B2.ba</CompositeItem>
    <CompositeItem>B2.bb</CompositeItem>
  </CompositeRow>
  <CompositeRow>
    <CompositeItem>C1</CompositeItem>
    <CompositeItem colSpan={ 2 }>C2</CompositeItem>
    <CompositeItem>C3</CompositeItem>
  </CompositeRow>
</Composite>

The second would be to allow Composite* items to nest, and take over/hand off directional navigation as appropriate:

<Composite store={ store }>
  <CompositeRow>
    <CompositeItem>A1</CompositeItem>
    <CompositeItem colSpan={ 2 }>A2</CompositeItem>
    <CompositeItem>A3</CompositeItem>
  </CompositeRow>
  <CompositeRow>
    <CompositeItem rowSpan={ 2 }>B1</CompositeItem>
    <CompositeItem>
      <CompositeRow>
        <CompositeItem>B2.aa</CompositeItem>
        <CompositeItem>B2.ab</CompositeItem>
      </CompositeRow>
      <CompositeRow>
        <CompositeItem>B2.ba</CompositeItem>
        <CompositeItem>B2.bb</CompositeItem>
      </CompositeRow>
    </CompositeItem>
    <CompositeItem rowSpan={ 2 }>B3</CompositeItem>
  </CompositeRow>
  <CompositeRow>
    <CompositeItem>C1</CompositeItem>
    <CompositeItem colSpan={ 2 }>C2</CompositeItem>
    <CompositeItem>C3</CompositeItem>
  </CompositeRow>
</Composite>

Alternatively/additionally, a more functional colId could be introduced, akin to the existing rowId, allowing for more explicit control. This would allow for more intelligent use of getNextId when the store is navigating, skipping over cells if they do not belong in the current row/column. This could perhaps have a declarative equivalent of the HTML <col>/<colgroup> elements.

Requirements

  • CompositeItem should have a means of declaring which column it belongs to (implicitly or explicitly)
  • store.up() should take into account explicit column ownership
  • store.down() should take into account explicit column ownership
  • store.previous() should take into account explicit column ownership
  • store.next() should take into account explicit column ownership

Workaround

No response

Possible implementations

Directional movement could be modified to take into account item row and column spans, but this would be rather naive...

return {
  ...
  next: (skip) => {
    ...
    return getNextId(..., skip + (item.colSpan - 1));
  },
  ...
  down: (skip) => {
    ...
    return getNextId(..., skip + (item.rowSpan - 1));
  },
  ...
}

This only helps when you can look ahead, of course – previous and up would require rather more work – and doesn't take into account any additional rowSpan or colSpan variants elsewhere in the column or row.

Instead, getNextId could be modified to take into account any *Span values set on items when traversing the item data:

//  Note: untested and possibly ill thought-out!

const getNextId = (...) {
  ...
  if (skip !== undefined) {
    const spanProp = isHorizontal ? 'colSpan' : 'rowSpan';
    let count = 0;
    let index = 0;
    while (count < skip && index < nextItemsInRow.length) {
      // Make sure we jump the appropriate number of cells
      index += nextItemsInRow[index][spanProp] || 1;
      // Only increment if the cell is enabled
      if (nextItemsInRow[index].enabled) count++;
    }
    const nextItem =
      nextItemsInRow[index] ||
      // If we can't find an item, just return the last one.
      getEnabledItems(nextItemsInRow).pop();
    return nextItem?.id;
  }
  ...
}

There would be other scenarios to validate too, I'm sure, such as in verticalizeItems and getMaxRowLength.

A similar approach could be taken if an implicit/explicit colId is supported, ensuring that items are appropriately excluded if they don't belong in the requested context.

@diegohaz
Copy link
Member

diegohaz commented Dec 8, 2023

Thanks for the comprehensive feature request, @andrewhayward!

I've been planning to introduce this feature into a dedicated Grid component, but haven't found the time to do so yet. From my past experiences, implementing colSpan was relatively straightforward. All you need to do is add more items pointing to the same id, though I'm uncertain if it's doable now.

The rowSpan posed more of a challenge, and I haven't moved forward with its implementation yet.

@andrewhayward
Copy link
Author

andrewhayward commented Dec 8, 2023

Do you have any suggestions for workarounds with the existing Composite* components? I would ideally like to be able to implement something like this...

<Composite store={ store } render={ <table /> }>
  <thead>
    <CompositeRow render={ <tr /> }>
      <th>
        <CompositeItem render={ <SortButton /> }>Name</CompositeItem>
      </th>
      <th>
        <CompositeItem render={ <SortButton /> }>Date</CompositeItem>
      </th>
      <CompositeItem render={ <th /> }>Can...</CompositeItem>
      <CompositeItem render={ <th /> }Actions</CompositeItem>
      <CompositeItem render={ <th /> }Useful information</CompositeItem>
    </tr>
  </thead>
  <tbody>
    { items.forEach((item) => (
      <CompositeRow render={ <tr /> }>
        <CompositeItem render={ <td /> }>{ item.displayName }</CompositeItem>
        <CompositeItem render={ <td /> }>{ item.date }</CompositeItem>
        <td>
          <CompositeItem render={ <ItemCanCheckbox item={ item } /> } />
        </td>
        <td>
          { item.actions.forEach((action) => (
            <CompositeItem render={ <ActionButton action={ action } /> } />
          ) }
        </td>
        <CompositeItem render={ <td /> }>{ item.usefulInformation }</CompositeItem>
      </CompositeRow>
    ) }
  </Composite>
</table>

Because the 'actions' column can contain any number of interactive widgets, there's no obvious way to set up the navigation correctly.

A table showing columns labelled "Name", "Date", "Can...", "Actions", and "Useful information". There are three rows, with arbitrary data. Each "actions" cell contains different buttons.

It would actually be great if instead each cell could remain a CompositeItem, and handle navigation actions like previous and next. Essentially, the store can proxy its own up/down/previous/next calls through each CompositeItem. 99% of the time this wouldn't be needed, and would behave as with the current implementation. But every now and then (see above!) it would be useful to delegate the order of items...

// This is very naive!

let currentActionIndex = null;

const getAndUpdateAction = ( index ) => {
  const action = actions[index];
  if ( !action ) return;
  currentActionIndex = index;
  return { id: action.id, element: action.ref };
}

const getNextAction = () => getAndUpdateAction( (currentActionIndex ?? -1) + 1 );
const getPreviousAction = () => getAndUpdateAction( (currentActionIndex ?? actions.length) - 1 );
...
<CompositeItem render={ <td /> } getPreviousItem={ getPreviousAction } getNextItem={ getNextAction }>
  { item.actions.forEach((action) => (
    <ActionButton action={ action } />
  ) }
</CompositeItem>
...

@andrewhayward
Copy link
Author

Let me know if that get*Item thought is worth its own issue, and I'll write it up. Feels distinct enough to be separate.

@diegohaz
Copy link
Member

diegohaz commented Dec 8, 2023

Do you have any suggestions for workarounds with the existing Composite* components? I would ideally like to be able to implement something like this...

Because the 'actions' column can contain any number of interactive widgets, there's no obvious way to set up the navigation correctly.

A table showing columns labelled "Name", "Date", "Can...", "Actions", and "Useful information". There are three rows, with arbitrary data. Each "actions" cell contains different buttons.

You can put all the action buttons into a single cell and follow the pattern described in Editing and Navigating Inside a Cell.

I think it will be less confusing than using colspan.

Ariakit has an experimental CompositeContainer component that can be used for this purpose:

import { CompositeContainer } from "@ariakit/react-core/composite/composite-container";

<CompositeItem render={<CompositeContainer />}>
  <input />
  <button />
  <Menu />
  <Select />
  etc.
</CompositeItem>

Let me know if that get*Item thought is worth its own issue, and I'll write it up. Feels distinct enough to be separate.

That can be a separate feature request, but I believe it could also be achieved with a custom onKeyDown with a manual call to compositeStore.move(id) and calling event.preventDefault() to stop the internal behavior from happening.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

2 participants