Skip to content
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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/use-sync-external-store": "^1.5.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.2",
"apca-w3": "^0.1.9",
Expand Down Expand Up @@ -154,6 +155,7 @@
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.5",
"use-sync-external-store": "^1.6.0",
"yaml": "^2.8.3",
"zod": "^4.3.6"
},
Expand Down
11 changes: 11 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions src/interactive-os/ui/KeyHintBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/** @catalog 컨텍스트 키바인딩 힌트 바 — 하단 풋터 배치 */
import type { ReactElement } from 'react'
import { ax } from '@styles/ax'
import { Kbd } from './Kbd'

export interface KeyHint {
keys: string[]
label: string
}

export interface KeyHintBarProps {
hints: readonly KeyHint[]
'aria-label'?: string
}

export function KeyHintBar({ hints, 'aria-label': ariaLabel }: KeyHintBarProps): ReactElement {
return (
<div
role="group"
aria-label={ariaLabel ?? 'Keyboard shortcuts'}
className={ax({ role: 'control-group', surface: 'sunken', layout: 'bar', width: 'full' })}
>
{hints.map((hint) => (
<span key={hint.label} className={ax({ role: 'item', layout: 'row', textStyle: 'caption', tone: 'neutral-dim' })}>
{hint.keys.map((k) => <Kbd key={k}>{k}</Kbd>)}
<span>{hint.label}</span>
</span>
))}
</div>
)
}
31 changes: 31 additions & 0 deletions src/interactive-os/ui/MockupBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/** @catalog 목업 플레이스홀더 바 — wireframe용 (ax 기반) */
import { ax } from '@styles/ax'
import type { AxWidth } from '@styles/ax'

interface MockupBarProps {
width?: AxWidth
shape?: 'bar' | 'dot'
}

/**
* Mockup-only primitive. Solid placeholder using role:'item' + surface:'display'.
* Width token sizes the horizontal extent; nbsp inside gives it text-line height.
* Shape 'dot' renders a square (aspect:'1') for avatar/star placeholders.
*
* Use inside Phase 3 WireframeWidgets. role:'item' is intentional:
* — it has both `width` and `surface` axes (placeholder role lacks surface strength in dark theme).
*/
export function MockupBar({ width = 'md', shape = 'bar' }: MockupBarProps) {
if (shape === 'dot') {
return (
<span className={ax({ role: 'item', surface: 'display', content: 'text', textStyle: 'body' })}>
{'\u00A0'}
</span>
)
}
return (
<span className={ax({ role: 'item', surface: 'display', content: 'text', textStyle: 'body', width })}>
{'\u00A0'}
</span>
)
}
235 changes: 11 additions & 224 deletions src/interactive-os/ui/TreeGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,29 @@
/** @catalog 트리+그리드 복합 테이블 */
import React from 'react'
/** @catalog 트리+그리드 복합 테이블 — 모드 분기 엔트리 */
import type React from 'react'

import type { NodeState } from '../pattern/types'
import type { AriaComponentProps, ItemSlots } from './types'
import type { AriaComponentProps } from './types'
import { Aria } from '../primitives/aria'
import { AriaInternalContext } from '../primitives/AriaInternalContext'
import { GRID_COL_ID } from '../axis/navigate'
import { treegrid } from '../pattern/roles/treegrid'
import { history } from '../plugins/history'
import { edit, replaceEditPlugin } from '../plugins/edit'
import { search } from '../plugins/search'
import { cellEdit } from '../plugins/cellEdit'
import { ExpandIndicator, SortIndicator } from './indicators'
import { TreeItem, EditableTreeItem } from './items'
import { ax } from '@styles/ax'
import { TreeGridColumns } from './TreeGridColumns'
import { TreeGridSimple } from './TreeGridSimple'

// ── Column mode (Grid-like columns + tree hierarchy) ──
// ── Column mode types ──

interface ColumnDef {
export interface ColumnDef {
key: string
header: string
width?: string
}

type RenderCell = (
export type RenderCell = (
props: React.HTMLAttributes<HTMLElement>,
value: unknown,
column: ColumnDef,
state: NodeState,
data?: Record<string, unknown>,
) => React.ReactElement

interface TreeGridColumnProps extends Omit<AriaComponentProps, 'renderItem'> {
export interface TreeGridColumnProps extends Omit<AriaComponentProps, 'renderItem'> {
id?: string
columns: ColumnDef[]
renderCell?: RenderCell
Expand All @@ -44,9 +36,9 @@ interface TreeGridColumnProps extends Omit<AriaComponentProps, 'renderItem'> {
keyMap?: Record<string, import('../axis/types').KeyHandler>
}

// ── Simple mode (TreeItem-based, no columns) ──
// ── Simple mode types ──

interface TreeGridSimpleProps extends AriaComponentProps {
export interface TreeGridSimpleProps extends AriaComponentProps {
id?: string
enableEditing?: boolean
columns?: number
Expand All @@ -58,215 +50,10 @@ function isColumnMode(props: TreeGridProps): props is TreeGridColumnProps {
return Array.isArray((props as TreeGridColumnProps).columns)
}

// ── Simple mode helpers ──

function makeRenderItem(editable: boolean, slots?: ItemSlots) {
const Item = editable ? EditableTreeItem : TreeItem
if (!slots) return (props: React.HTMLAttributes<HTMLElement>, node: Record<string, unknown>, state: NodeState): React.ReactElement =>
Item(props, node, state)
return (props: React.HTMLAttributes<HTMLElement>, node: Record<string, unknown>, state: NodeState): React.ReactElement =>
Item(props, node, state, {
icon: slots.icon?.(node, state),
rightContent: slots.rightContent?.(node, state),
})
}

// ── Column mode: default cell renderer ──

const defaultRenderCell: RenderCell = (props, value, _column, _state) => (
<span {...props}>{String(value ?? '')}</span>
)

// Re-export Cell for grid consumers (e.g. TreegridEmail)
// eslint-disable-next-line react-refresh/only-export-components
export const Cell = Aria.Cell

/** Row wrapper that surfaces `data-row-mode` when colIndex === -1 so CSS can paint
* the whole row (not individual cells) as the active selection target. */
function TreeGridRow({
ariaProps,
focused,
selected,
children,
}: {
ariaProps: React.HTMLAttributes<HTMLElement>
focused: boolean
selected: boolean
children: React.ReactNode
}) {
const aria = React.useContext(AriaInternalContext)
const store = aria?.getStore()
const colIndex = (store?.entities[GRID_COL_ID]?.colIndex as number | undefined) ?? -1
const rowMode = colIndex < 0
return (
<div
className={ax({ role: 'item', interactive: 'item' })}
data-focused={focused || undefined}
data-selected={selected || undefined}
data-row-mode={rowMode && focused ? '' : undefined}
{...ariaProps}
>
{children}
</div>
)
}

// ── Column mode component ──

function TreeGridColumns({
id,
data,
columns,
plugins: userPlugins,
onChange,
onActivate,
onFocusChange,
renderCell = defaultRenderCell,
enableEditing = false,
searchable = false,
header = false,
sortKey,
sortDir,
onSortColumn,
keyMap,
'aria-label': ariaLabel,
}: TreeGridColumnProps) {
const plugins = userPlugins ?? []
const pattern = React.useMemo(
() => treegrid(columns.length),
[columns.length],
)

const mergedPlugins = React.useMemo(
() => {
const result = [...plugins]
if (enableEditing) { result.push(edit({ tree: true }), replaceEditPlugin(), cellEdit()) }
if (searchable) { result.push(search()) }
return result
},
[plugins, enableEditing, searchable],
)

const gridStyle = React.useMemo(
() => {
const hasCustomWidth = columns.some(c => c.width)
if (hasCustomWidth) {
return { '--grid-columns': columns.map(c => c.width ?? '1fr').join(' ') } as React.CSSProperties
}
return { '--grid-col-count': columns.length } as React.CSSProperties
},
[columns],
)

const renderRow = (props: React.HTMLAttributes<HTMLElement>, node: Record<string, unknown>, state: NodeState): React.ReactElement => {
const data = node.data as Record<string, unknown> | undefined
const cells = data?.cells as unknown[] | undefined
const hasChildren = state.expanded !== undefined
const depth = (state.level ?? 1) - 1

return (
<TreeGridRow
ariaProps={props}
focused={state.focused}
selected={state.selected}
>
{columns.map((col, i) => (
<Aria.Cell key={col.key} index={i}>
{i === 0 ? (
<span className={ax({ layout: 'bar' })} style={{ paddingInlineStart: `${depth * 24}px` }}>
{hasChildren
? <ExpandIndicator expanded={state.expanded} hasChildren variant="tree" />
: depth > 0 && <span className={ax({ flex: 'none' })} />}
{renderCell({} as React.HTMLAttributes<HTMLElement>, cells?.[i], col, state, data)}
</span>
) : (
renderCell({} as React.HTMLAttributes<HTMLElement>, cells?.[i], col, state, data)
)}
</Aria.Cell>
))}
</TreeGridRow>
)
}

return (
<div className={ax({ layout: 'table' })} style={gridStyle}>
{header && (
<div role="row" className={ax({ placement: 'sticky' })}>
{columns.map((col) => (
<div key={col.key} role="columnheader">
<button
type="button"
className={ax({ role: 'item', interactive: 'button', textStyle: 'caption', tone: 'neutral-dim', content: 'text', layout: 'bar' })}
onClick={onSortColumn ? () => onSortColumn(col.key) : undefined}
>
{col.header}
{sortKey === col.key && <SortIndicator direction={sortDir === 'asc' ? 'ascending' : 'descending'} />}
</button>
</div>
))}
</div>
)}
<Aria
id={id}
pattern={pattern}
data={data}
plugins={mergedPlugins}
onChange={onChange}
onActivate={onActivate}
onFocusChange={onFocusChange}
keyMap={keyMap}
aria-label={ariaLabel}
>
{searchable && <Aria.Search placeholder="Search..." />}
<Aria.Item render={renderRow} />
</Aria>
</div>
)
}

// ── Simple mode component ──

function TreeGridSimple({
id,
data,
plugins = [history()],
onChange,
renderItem,
itemSlots,
enableEditing = false,
columns,
onActivate,
onFocusChange,
'aria-label': ariaLabel,
}: TreeGridSimpleProps) {
const defaultRenderer = React.useMemo(() => makeRenderItem(enableEditing, itemSlots), [enableEditing, itemSlots])
const resolvedRenderItem = renderItem ?? defaultRenderer
const pattern = React.useMemo(
() => columns ? treegrid(columns) : treegrid(1),
[columns],
)

const mergedPlugins = React.useMemo(
() => enableEditing ? [...plugins, edit({ tree: true }), replaceEditPlugin()] : plugins,
[plugins, enableEditing],
)

return (
<Aria
id={id}
pattern={pattern}
data={data}
plugins={mergedPlugins}
onChange={onChange}
onActivate={onActivate}
onFocusChange={onFocusChange}
aria-label={ariaLabel}
>
<Aria.Item render={resolvedRenderItem} />
</Aria>
)
}

// ── Public API ──

export function TreeGrid(props: TreeGridProps) {
Expand Down
Loading
Loading