|
| 1 | +# BAINameActionCell — Reusable Table Cell Layout Component |
| 2 | + |
| 3 | +> **Status**: Implemented |
| 4 | +> **Date**: 2026-03-28 |
| 5 | +> **Issue**: [FR-2408](https://lablup.atlassian.net/browse/FR-2408) |
| 6 | +> **Implemented**: PR [#6247](https://github.com/lablup/backend.ai-webui/pull/6247) |
| 7 | +
|
| 8 | +## Overview |
| 9 | + |
| 10 | +A reusable table cell layout component that combines a title area (icon + text) with responsive action buttons. When the column is wide enough, all action buttons are visible; as the column narrows, actions collapse one-by-one into a "more" overflow menu. This standardizes the repeated pattern of title + hover action buttons seen in `VFolderNodes` and other table components. |
| 11 | + |
| 12 | +## Problem Statement |
| 13 | + |
| 14 | +Currently, table components like `VFolderNodes` implement the title + action buttons pattern manually: |
| 15 | + |
| 16 | +- Hover state management via `onCell` + `onMouseEnter/onMouseLeave` + `useState` (causes React re-renders) |
| 17 | +- Manual `Tooltip` wrapping for each action button |
| 18 | +- Manual `BAIFlex` layout for button alignment |
| 19 | +- No responsive overflow handling — action buttons overflow or get clipped when the column is narrow |
| 20 | +- Separate "controls" column required, wasting horizontal space |
| 21 | + |
| 22 | +This leads to code duplication, inconsistent UX, and no responsive behavior for action buttons. |
| 23 | + |
| 24 | +## User Stories |
| 25 | + |
| 26 | +- **As a developer**, I want a declarative component where I define actions as a config array (key, title, icon, onClick, type) and the component handles layout, tooltips, hover visibility, and responsive overflow automatically. |
| 27 | +- **As a developer**, I want the title area to support navigation via `to` (React Router) or `onTitleClick`, with automatic ellipsis when space is limited. |
| 28 | +- **As a user**, I want to see action buttons when I hover over a table row, without them taking up space when I'm not interacting. |
| 29 | +- **As a user**, when the column is narrow, I want a "more" button that shows all available actions in a dropdown menu with icons and labels. |
| 30 | + |
| 31 | +## Requirements |
| 32 | + |
| 33 | +### Must Have |
| 34 | + |
| 35 | +- [x] Reusable component (`BAINameActionCell`) placed in `packages/backend.ai-ui/src/components/Table/` |
| 36 | +- [x] Title area with optional icon, text content, and navigation support (`to` or `onTitleClick`) |
| 37 | +- [x] Title auto-ellipsis with tooltip on overflow (via `BAIText` / `BAILink`) |
| 38 | +- [x] Actions defined as a config array with: `key`, `title`, `icon`, `onClick`, `action` (async), `type` (default/danger), `disabled`, `disabledReason` |
| 39 | +- [x] CSS-only hover visibility (`opacity` + `pointer-events`) — no React state needed |
| 40 | +- [x] Keyboard accessibility via `:focus-within` |
| 41 | +- [x] ResizeObserver-based responsive overflow calculation with `requestAnimationFrame` debounce |
| 42 | +- [x] Actions collapse right-to-left into a "more" (`MoreOutlined`) dropdown menu |
| 43 | +- [x] Overflow menu shows all actions with icon + title (full discoverability) |
| 44 | +- [x] `showActions` prop: `'hover'` (default) or `'always'` |
| 45 | +- [x] `minVisibleActions` prop to guarantee minimum visible action count |
| 46 | +- [x] Danger-type actions styled with error colors in both button and menu form |
| 47 | +- [x] Disabled actions with `disabledReason` shown as tooltip |
| 48 | +- [x] Async `action` prop using `useTransition` for automatic loading state (mirrors `BAIButton.action`) |
| 49 | +- [x] Exported from `packages/backend.ai-ui/src/components/Table/index.ts` |
| 50 | + |
| 51 | +### Nice to Have |
| 52 | + |
| 53 | +- [ ] Integration example: migrate `VFolderNodes` name + controls columns into a single column using `BAINameActionCell` |
| 54 | + |
| 55 | +## Component API |
| 56 | + |
| 57 | +### BAINameActionCellAction |
| 58 | + |
| 59 | +```typescript |
| 60 | +interface BAINameActionCellAction { |
| 61 | + key: string; // Unique key for React rendering |
| 62 | + title: string; // Tooltip on buttons, text in menu |
| 63 | + icon?: React.ReactNode; // Icon for both button and menu |
| 64 | + onClick?: () => void; // Sync click handler |
| 65 | + action?: () => Promise<void>; // Async handler with auto-loading (useTransition) |
| 66 | + type?: 'default' | 'danger'; // Visual style |
| 67 | + disabled?: boolean; // Disable the action |
| 68 | + disabledReason?: string; // Tooltip when disabled |
| 69 | +} |
| 70 | +``` |
| 71 | + |
| 72 | +### BAINameActionCellProps |
| 73 | + |
| 74 | +```typescript |
| 75 | +interface BAINameActionCellProps { |
| 76 | + // Title area (left side) |
| 77 | + icon?: React.ReactNode; // Icon before title |
| 78 | + title?: React.ReactNode; // Title text or custom content |
| 79 | + to?: LinkProps['to']; // React Router navigation |
| 80 | + onTitleClick?: (e: React.MouseEvent) => void; // Title click handler |
| 81 | + |
| 82 | + // Actions area (right side) |
| 83 | + actions?: BAINameActionCellAction[]; // Action definitions |
| 84 | + |
| 85 | + // Behavior |
| 86 | + showActions?: 'hover' | 'always'; // Default: 'hover' |
| 87 | + minVisibleActions?: number; // Default: 0 |
| 88 | + |
| 89 | + // Styling |
| 90 | + style?: React.CSSProperties; |
| 91 | + className?: string; |
| 92 | +} |
| 93 | +``` |
| 94 | + |
| 95 | +## Layout Structure |
| 96 | + |
| 97 | +``` |
| 98 | +┌─────────────────────────────────────────────────────────┐ |
| 99 | +│ [icon] Title text (ellipsis...) [📝] [🔗] [🗑️] [...] │ |
| 100 | +│ ├── titleArea (flex: 1) ──────┤ ├── actionsArea ────┤ │ |
| 101 | +└─────────────────────────────────────────────────────────┘ |
| 102 | +``` |
| 103 | + |
| 104 | +- **titleArea**: `flex: 1, min-width: 0` — fills remaining space, ellipsis on overflow |
| 105 | +- **actionsArea**: `flex-shrink: 0` — fixed width, shown on hover |
| 106 | + |
| 107 | +## Behavior Details |
| 108 | + |
| 109 | +### Hover Visibility (CSS-only) |
| 110 | + |
| 111 | +- Default state: `opacity: 0` + `pointer-events: none` |
| 112 | +- On `:hover` or `:focus-within`: `opacity: 1` + `pointer-events: auto` |
| 113 | +- Transition: `opacity 0.15s ease` |
| 114 | +- No React state management — zero re-renders for hover |
| 115 | + |
| 116 | +### Responsive Overflow |
| 117 | + |
| 118 | +- `ResizeObserver` monitors container width |
| 119 | +- `requestAnimationFrame` debounces calculation to prevent infinite loops |
| 120 | +- Actions collapse right-to-left (last action overflows first) |
| 121 | +- When overflow occurs, a `MoreOutlined` button appears |
| 122 | +- More menu contains **all** actions (not just overflowed), ensuring full discoverability |
| 123 | + |
| 124 | +``` |
| 125 | +Wide: [Edit] [Share] [Copy] [Delete] |
| 126 | +Medium: [Edit] [Share] [...] |
| 127 | +Narrow: [Edit] [...] |
| 128 | +Minimum: [...] |
| 129 | +``` |
| 130 | + |
| 131 | +### Title Rendering |
| 132 | + |
| 133 | +| Props provided | Renders as | Behavior | |
| 134 | +|----------------------|--------------|----------------------------------| |
| 135 | +| `to` | `BAILink` | React Router link + ellipsis | |
| 136 | +| `onTitleClick` | `BAILink` | Clickable text + ellipsis | |
| 137 | +| Neither | `BAIText` | Plain text + tooltip on overflow | |
| 138 | + |
| 139 | +### Action Rendering |
| 140 | + |
| 141 | +| Context | Icon button | Overflow menu item | |
| 142 | +|-----------------|----------------------------------|---------------------------------| |
| 143 | +| Normal | `BAIButton type="text" size="small"` + Tooltip | Icon + title text | |
| 144 | +| Danger | `danger` prop on BAIButton | `danger: true` on menu item | |
| 145 | +| Disabled | `disabled` + `disabledReason` tooltip | `disabled` on menu item | |
| 146 | +| Async (`action`)| `BAIButton.action` (useTransition) | `startTransition` wrapper | |
| 147 | + |
| 148 | +## Implementation Details |
| 149 | + |
| 150 | +### Key Dependencies |
| 151 | + |
| 152 | +| Component | Source | Usage | |
| 153 | +|------------|-------------------------------------|---------------------------------| |
| 154 | +| `BAIText` | `../BAIText` | Ellipsis with ResizeObserver | |
| 155 | +| `BAILink` | `../BAILink` | Navigation + ellipsis | |
| 156 | +| `BAIButton`| `../BAIButton` | Action buttons (async support) | |
| 157 | +| `Dropdown` | `antd` | Overflow menu | |
| 158 | +| `Tooltip` | `antd` | Action button labels | |
| 159 | +| `createStyles` | `antd-style` | CSS-in-JS (project convention) | |
| 160 | + |
| 161 | +### Performance |
| 162 | + |
| 163 | +- CSS-only hover: zero React re-renders for show/hide |
| 164 | +- `ResizeObserver` callback debounced via `requestAnimationFrame` |
| 165 | +- Cleanup on unmount: `ro.disconnect()` + `cancelAnimationFrame(rafId)` |
| 166 | +- `visibleCount` reset when `actions` array length changes |
| 167 | +- `'use memo'` directive for React Compiler optimization |
| 168 | + |
| 169 | +### Accessibility |
| 170 | + |
| 171 | +- `:focus-within` ensures keyboard users can discover actions via Tab |
| 172 | +- `aria-label="More actions"` on the overflow button |
| 173 | +- antd `Dropdown` + `Menu` provides `role="menu"` and `role="menuitem"` automatically |
| 174 | +- Disabled actions communicate reason via tooltip |
| 175 | + |
| 176 | +## Storybook Stories |
| 177 | + |
| 178 | +| Story | Description | |
| 179 | +|--------------------|----------------------------------------------------| |
| 180 | +| Basic | Icon + title + 4 actions (edit, share, copy, delete)| |
| 181 | +| WithNavigation | `to` prop for React Router link | |
| 182 | +| AlwaysShowActions | `showActions: 'always'` | |
| 183 | +| WithDisabledAction | Disabled action with `disabledReason` | |
| 184 | +| LongTitle | Narrow container (300px), ellipsis behavior | |
| 185 | +| ResponsiveOverflow | Interactive slider to resize container | |
| 186 | +| TitleOnly | No actions, title only | |
| 187 | +| AsyncAction | `action` prop with 2s delay, loading spinner | |
| 188 | + |
| 189 | +## Usage Example |
| 190 | + |
| 191 | +```tsx |
| 192 | +// VFolderNodes — replacing separate name + controls columns |
| 193 | +{ |
| 194 | + key: 'name', |
| 195 | + title: t('data.folders.Name'), |
| 196 | + render: (_name, vfolder) => ( |
| 197 | + <BAINameActionCell |
| 198 | + icon={<VFolderNodeIdenticon vfolderNodeIdenticonFrgmt={vfolder} />} |
| 199 | + title={vfolder.name} |
| 200 | + to={generateFolderPath(toLocalId(vfolder?.id))} |
| 201 | + actions={[ |
| 202 | + { |
| 203 | + key: 'share', |
| 204 | + title: t('button.Share'), |
| 205 | + icon: <BAIShareAltIcon />, |
| 206 | + onClick: () => setInviteFolderId(toLocalId(vfolder?.id)), |
| 207 | + }, |
| 208 | + { |
| 209 | + key: 'delete', |
| 210 | + title: t('data.folders.MoveToTrash'), |
| 211 | + icon: <BAITrashBinIcon />, |
| 212 | + type: 'danger', |
| 213 | + onClick: () => handleDelete(vfolder), |
| 214 | + }, |
| 215 | + ]} |
| 216 | + /> |
| 217 | + ), |
| 218 | +} |
| 219 | +``` |
| 220 | + |
| 221 | +## File Manifest |
| 222 | + |
| 223 | +| File | Action | |
| 224 | +|------|--------| |
| 225 | +| `packages/backend.ai-ui/src/components/Table/BAINameActionCell.tsx` | Created | |
| 226 | +| `packages/backend.ai-ui/src/components/Table/BAINameActionCell.stories.tsx` | Created | |
| 227 | +| `packages/backend.ai-ui/src/components/Table/index.ts` | Modified (export added) | |
0 commit comments