Skip to content

feat(resizable): DLT-2097 add DtResizable panel layout component#1162

Open
Joshua Hynes (hynes-dialpad) wants to merge 33 commits intostagingfrom
feat/DLT-2097-resizable-component
Open

feat(resizable): DLT-2097 add DtResizable panel layout component#1162
Joshua Hynes (hynes-dialpad) wants to merge 33 commits intostagingfrom
feat/DLT-2097-resizable-component

Conversation

@hynes-dialpad
Copy link
Copy Markdown
Member

@hynes-dialpad Joshua Hynes (hynes-dialpad) commented Mar 31, 2026

Obligatory GIF (super important!)

Obligatory GIF

🛠️ Type Of Change

  • Fix
  • Feature
  • Performance Improvement
  • Refactor

📖 Jira Ticket

DLT-2097

Stories:

  • DLT-3234 — Core resize engine + constraints
  • DLT-3235 — Persistence + storage adapter
  • DLT-3236 — Keyboard accessibility + edit mode
  • DLT-3237 — Peek, offset, useDOMCache, docs

📖 Description

New DtResizable component system — a resizable panel layout ported from Beacon and genericized for Dialtone. Three components, 12 composables, 1 shared composable, 173 tests, 13 Storybook stories, and a full VuePress docs page.

Components

  • DtResizable — Layout orchestrator (direction, collapse rules, storage, edit mode, i18n messages)
  • DtResizablePanel — Individual panel (constraints, collapse, peek overlay)
  • DtResizableHandle — Drag handle (keyboard resize, ARIA, offset positioning)

Key Features

  1. Core resize — Drag handles between panels with proportional sizing, row/column direction
  2. Dual constraint hierarchy — userMin/Max (drag limits) + systemMin/Max (viewport resize limits)
  3. Collapse — Manual + auto-collapse with priority-based collapseRules, space allocation strategies
  4. PersistencestorageKey for localStorage, :storage prop for custom adapters (Pinia/Vuex/API)
  5. Keyboard accessibility — Ctrl+E edit mode, arrow keys (8/24/1px increments), ARIA live announcements
  6. Peek overlay — Hover/button preview for collapsed panels with configurable grace period
  7. Offset positioning — Handle offset from external DOM elements via useDOMCache shared composable
  8. i18n — All ARIA strings configurable via messages prop with English defaults

Architecture

  • Pure layout engine (computeLayout.ts) with zero Vue dependencies
  • Shadow DOM drag (useResizableDrag.ts) — bypasses Vue reactivity for 60fps inline style writes
  • Three-layer composable architecture: pure engine → state management → orchestration
  • Size tokens resolved from --dt-size-* CSS custom properties at runtime (auto-syncs with token pipeline)

📦 Cross-Package Impact

Package Changes Downstream Impact
dialtone-vue New component + shared composable Documentation site needs component page
dialtone-documentation New VuePress page + sidebar entry None

Note: Styles are inline in Vue SFCs (no dialtone-css changes) — this component requires JS for drag interaction; no CSS-only fallback exists.

📄 Documentation Artifacts

Artifact Status Notes
Vue source 3 components in packages/dialtone-vue/components/resizable/
Tests 173 tests across 9 test files
Storybook stories 13 stories + MDX docs page
Component docs JSON ⚠️ vue-docgen-api expects resizable.vue but files use dt_resizable.vue — follow-up needed
VuePress docs docs/components/resizable.md with full API tables
MCP server data ⚠️ Blocked by component docs JSON naming convention — follow-up
components_list.js All 3 components registered
Package exports Exported from packages/dialtone-vue/index.js

💡 Context

This is an FY27 Q1 Key Result under the "Dialtone Next and Beacon Alignment" initiative (DLT-2979). The resizable panel system was originally built for Beacon's sidebar/main/peek layout. This PR ports it to Dialtone as a first-class component, genericized for any resizable panel layout use case.

The component was shaped, breadboarded, and sliced into 7 vertical increments before implementation. The shaping artifacts are in the session notes, not in this repo.

📝 Checklist

For all PRs:

  • I have ensured no private Dialpad links or info are in the code or pull request description (Dialtone is a public repo!).
  • I have reviewed my changes.
  • I have added all relevant documentation.
  • I have considered the performance impact of my change.

For all Vue changes:

  • I have added / updated unit tests.
  • I have validated components with a screen reader.
  • I have validated components keyboard navigation.

For all CSS changes:

  • I have used design tokens whenever possible.
  • I have considered how this change will behave on different screen sizes.
  • I have visually validated my change in light and dark mode.
  • I have used gap or flexbox properties for layout instead of margin whenever possible.

If new component:

  • I am exporting any new components or constants:
    • from the index.js in the component directory.
    • from the index.js in the root (packages/dialtone-vue).
  • I have added the styles for the new component to the packages/dialtone-css package. (N/A — styles inline in SFCs, component requires JS)
  • I have created a page for the new component on the documentation site in apps/dialtone-documentation.
  • I have added the new component to common/components_list.js
  • I have created a component story in storybook
  • I have created story / stories for any relevant component variants in storybook
  • I have created a docs page for the component in storybook.
  • I have checked that changing all props/slots via the UI in storybook works as expected.

🔮 Next Steps

  • MCP data: Reconcile vue-docgen-api naming convention (resizable.vue vs dt_resizable.vue) so component docs auto-populate
  • Layout tokens: Map size tokens to dedicated --dt-layout-* tokens when they land on the next branch
  • Screen reader validation: Full VoiceOver/NVDA walkthrough of edit mode and announcements
  • Light/dark mode: Visual validation of handle styles, peek overlay, and focus indicators in both themes
  • Storybook controls: Verify all props are interactive in the Storybook controls panel

📷 Screenshots / GIFs

Deploy preview will show Storybook stories for all 13 variants.

🔗 Sources

  • Beacon source: beacon-app/src/components/ui/resizable/
  • Shaping docs: Session notes (2026-03-30)
  • ARIA separator pattern: WAI-ARIA 1.2 separator role

Port resizable panel system from beacon-app as DtResizable.
Three components (DtResizable, DtResizablePanel, DtResizableHandle),
10 composables with three-layer architecture (pure engine, state
management, orchestration), drag interaction with shadow DOM for
60fps, and 63 passing tests across 4 Storybook stories.

V1 of 7 vertical slices — core layout engine only.
… control (V2)

- Re-add panels and spaceAllocationStrategy props to dt_resizable.vue
- Wire spaceAllocationStrategy through scoped slot and defineExpose
- Add 37 V2 tests covering constraint enforcement, auto-collapse rules,
  space allocation strategies, and programmatic control API surface
- Add 3 Storybook stories: Constraints, Collapsible, Programmatic
- Register V2 stories in dt_resizable.stories.js index
…ce (V3)

- Add ResizableStorageAdapter interface and ResizableStoragePanelData type
- Refactor useResizableStorage with localStorageAdapter(key) factory
- Add :storage prop for custom adapter injection (Pinia, Vuex, IndexedDB)
- Wire storageAdapter through useResizableGroup options
- Custom adapter takes precedence over storageKey when both provided
- 28 tests: save/load cycle, validation, corrupted data, adapter precedence
- 2 stories: localStorage persistence demo, custom adapter example
…and package exports (V7)

- Register dt_resizable, dt_resizable_panel, dt_resizable_handle in components_list.js
- Export resizable components and common/composables from package index
- Add Resizable entry to site-nav.json sidebar navigation
- Create VuePress docs page with usage, constraints, collapsible, persistence,
  peek overlay, keyboard accessibility, programmatic control, and full API tables
- Extract DEFAULT_PANEL_SIZE and MIN_PANEL_SIZE_PX constants (was '50p'
  hardcoded in 9 locations, magic number 10 in 3)
- Scope useDOMCache MutationObserver to container via observeRoot option
  (was observing document.body globally, invalidating on all DOM changes)
- Add ref-counted cleanup for announcement DOM element (was never removed)
- Remove void statements in keyboard resize callback (was suppressing
  unused-var warnings for no-op)
- Remove dead useResizableGroupSetup export from barrel
…e dead code

- Add clampSize() and clampToTier() to constraintResolver.ts as the
  single source of truth for constraint clamping. Replace 4 duplicate
  implementations across useResizablePanelState, useResizableCalculations,
  useResizablePanelControls, and computeLayout.
- Replace hardcoded SIZE_TOKENS map with runtime CSS custom property
  resolution (reads --dt-size-{token} from getComputedStyle). Falls
  back to static map in test/SSR environments. Percentages now parsed
  dynamically from the 'p' suffix pattern.
- Remove dead useResizableGroupSetup (404 lines) — replaced by
  useResizableGroup.ts in V1, never imported by any component.
  useResizableCore.ts reduced from 623 to 195 lines.
- Fix handle class prop to accept String, Object, Array (was String-only)
- Replace hardcoded 2px in focus-visible with --dt-size-200/300 tokens
- Add comment explaining panelsChanged custom comparator rationale
- Note storageKey/storage capture-at-mount limitation
Add missing story imports and exports for ResizableKeyboard,
ResizablePeekHover, ResizablePeekButton, and ResizableOffset
templates in dt_resizable.stories.js.
…t.each

Replace repetitive it() blocks with parameterized it.each tables
in KEYBOARD_INCREMENTS and offsetDirection test suites.
…announcements

- Add ResizableEditModeMessages interface with optional fields for all
  edit mode announcements (editModeActivated, editModeDeactivated,
  editModeNoHandles, panelsReset, allPanelsReset) with English defaults
- Add ResizableKeyboardMessages interface with resizeAnnouncement template
  using {beforeId}, {afterId}, {beforePx}, {afterPx}, {action},
  {incrementType} placeholders
- Add ariaLabel prop to DtResizableHandle for i18n override of the
  default computed aria-label
- Add messages prop to DtResizable, wired through provide/inject to
  both useResizableEditMode and useResizableKeyboard
- Add RESIZABLE_MESSAGES_KEY injection key for cross-component messaging
…tcuts and aria-description

- Remove createInstructions() that injected a hidden DOM element with
  hardcoded English instructions linked via aria-describedby
- Add aria-keyshortcuts attribute to handle: "Control+e" when inactive,
  full shortcut list when in edit mode
- Add aria-description attribute with contextual hint: edit mode entry
  prompt when inactive, full controls summary when active
- Add editModeDescription and editModeActiveDescription to
  ResizableEditModeMessages interface for i18n support
- Update tests to verify new ARIA attributes instead of DOM element
- Remove stale dt-resize-instructions cleanup from test afterEach hooks
@wiz-inc-55b470eb7e
Copy link
Copy Markdown

wiz-inc-55b470eb7e bot commented Mar 31, 2026

Wiz Scan Summary

Scanner Findings
Vulnerability Finding Vulnerabilities -
Data Finding Sensitive Data -
Secret Finding Secrets -
IaC Misconfiguration IaC Misconfigurations -
SAST Finding SAST Findings 4 Medium
Software Management Finding Software Management Findings -
Total 4 Medium

View scan details in Wiz

To detect these findings earlier in the dev lifecycle, try using Wiz Code VS Code Extension.

…compat

All other Dialtone component barrels use index.js (not index.ts).
The TS compiler cannot resolve .vue module imports from .ts files
without vue shim declarations. Renaming to .js matches the
established convention and fixes the build.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 22b40c7af6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".

Comment on lines +419 to +422
if (globalEditMode.keydownListener === handleGlobalKeydown) {
document.removeEventListener('keydown', globalEditMode.keydownListener);
document.removeEventListener('click', handleDocumentClick, true);
globalEditMode.keydownListener = null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve edit-mode listeners while any resizable remains mounted

In useResizableEditMode, listener teardown is tied to function identity instead of active-instance count: if the instance that originally registered globalEditMode.keydownListener unmounts before later instances, lines 419–422 remove the global keydown/click listeners even though other DtResizable components are still mounted. In that multi-instance lifecycle (mount A, mount B, unmount A), Ctrl/Cmd+E and edit-mode navigation stop working for B until remount.

Useful? React with 👍 / 👎.

Comment on lines +233 to +236
`${beforePanelId.value}:${afterPanelId.value}`,
panels.value,
0,
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Compute keyboard cursor from panel offset for non-first handles

Keyboard resize passes beforePanelLeft as 0 and computes newCursorPosition from only beforePanel.pixelSize, but processResizeMove expects a container-relative cursor plus the true left offset of the before panel. For any handle after the first panel in a 3+ panel layout, arrow-key resize will calculate sizes as if the pair started at x=0, causing incorrect widths/jumps and potential total-width overflow in the immediate DOM update.

Useful? React with 👍 / 👎.

Comment on lines +303 to +307
if (behavior === 'all') {
resetAllPanelPairs();
} else {
resetSpecificPanelPair(beforePanelId, afterPanelId);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor resetBehavior values other than "all"

resetPanels collapses every non-all behavior (both, before, after) into the same resetSpecificPanelPair path, so DtResizableHandle’s documented resetBehavior="before"|"after" options are ignored. As implemented, those modes cannot produce different behavior from both, which breaks the advertised API contract for handle reset semantics.

Useful? React with 👍 / 👎.

The typedoc build-functions target generates "Date and Time" docs
from common/*/index.js. The new common/composables/ directory
(useDOMCache) is not part of that scope. Excluding it prevents
potential resolution issues with .ts entry points in CI.
…ical stories

The Default and Vertical stories lacked docs.source.code overrides,
causing Storybook to auto-generate incorrect code showing the wrapper
component tag (dt_resizable) instead of the actual template with
DtResizablePanel and DtResizableHandle children.
…ult direction

- Convert markdown tables to HTML tables for MDX rendering compatibility
- Remove unnecessary direction="row" from code examples (it's the default)
@github-actions
Copy link
Copy Markdown
Contributor

Please add either the visual-test-ready or no-visual-test label to this PR depending on whether you want to run visual tests or not.
It is recommended to run visual tests if your PR changes any UI. ‼️

@hynes-dialpad Joshua Hynes (hynes-dialpad) added the no-visual-test Add this tag when the PR does not need visual testing label Mar 31, 2026
…over

- Add d-w100p class to all story panel content divs so they fill width
- Add styles for __indicator element (was unstyled in template)
- Move hover/active background-color to __indicator with 150ms transition
Replace physical properties (top/left/right/bottom) with logical
properties (inset-inline-start/end, inset-block, inline-size).
Container sets writing-mode: vertical-lr for column direction,
so all positioning rotates automatically.

- Panel: inset-block: 0 + insetInlineStart/End (was top/bottom: 0 + left/right)
- Handle: inset-block + inline-size (was separate row/column blocks)
- Hit area: inset shorthand (was 4 physical properties)
- Indicator: inset: 0 (was duplicate row/column blocks)
- Panel content: writing-mode: horizontal-tb reset for normal text
- Removed direction-switching JS in handle positioning
Define --dt-resizable-handle-color-surface on the handle root,
defaulting to --dt-color-surface-info-strong. All hover, active,
and focus-visible styles reference the component variable instead
of the raw token — single place to update for theming.
resetAdjacentPanels was simulating a drag with beforePanelLeft=0,
which produced wrong results for any handle that isn't the first.
Now redistributes the combined space of both panels by their
initial size ratio — correct for any position in the layout.
Rename dt_resizable*.vue/js/mdx → resizable*.vue/js/mdx to match
the Dialtone naming convention where filenames mirror the folder
name (e.g., badge/badge.vue, not badge/dt_badge.vue).

This enables vue-docgen-api to discover the component files via
the getValidFileList regex (^{folderName}\w*\.vue$), which powers
the auto-generated <component-vue-api> tables on the docs site.

Replace hand-written Props/Events/Slots HTML tables in the docs
page with <component-vue-api> tags for DtResizable,
DtResizablePanel, and DtResizableHandle.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just an initial quick response for style.

Dialtone CSS classes use d- prefix (d-modal, d-badge) while
component names use Dt prefix (DtModal, DtBadge). Renamed CSS
classes: d-resizable, d-resizable-panel, d-resizable-handle.
Updated components_list.js to match renamed filenames.
- opacity: 0.3 → var(--dt-opacity-500)
- transition: 150ms → var(--td150)
- handle color: --dt-color-surface-info-strong → --dt-color-border-focus
- focus-visible: simplified to background-color + box-shadow: none
Move all component styles from inline <style> blocks in Vue SFCs
to packages/dialtone-css/lib/build/less/components/resizable.less.
This matches the Dialtone convention where styles ship via the CSS
package and are loaded by the documentation site.

Includes peek overlay transition styles with --td150 token.
@github-actions
Copy link
Copy Markdown
Contributor

✔️ Deploy previews ready!
😎 Dialtone documentation preview: https://dialtone.dialpad.com/deploy-previews/pr-1162/
😎 Dialtone-vue preview: https://dialtone.dialpad.com/vue/deploy-previews/pr-1162/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-visual-test Add this tag when the PR does not need visual testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants