Skip to content

Repeater: TUI Accordion/Tabs layouts, ConfirmDialog, reordering, icon actions - #93

Open
juliacanzani wants to merge 22 commits into
mainfrom
feat/repeater-improvements
Open

Repeater: TUI Accordion/Tabs layouts, ConfirmDialog, reordering, icon actions#93
juliacanzani wants to merge 22 commits into
mainfrom
feat/repeater-improvements

Conversation

@juliacanzani

Copy link
Copy Markdown
Contributor

Summary

Rebuilds the repeater layouts on @tangible/ui 0.2.20 and replaces the remaining hand-rolled interaction patterns with TUI components.

Shared

  • ConfirmDialog / ConfirmTrigger on TUI Modal (role="alertdialog", Cancel focused first, Escape cancels, focus restored to the trigger, or handed to the next row when a delete destroys the trigger). Replaces the react-aria ModalTrigger confirms; ModalTrigger is deprecated but kept for the conditional panel and modal element.
  • usePortalContainer hook shared by TUI modals so they land inside the fields context wrapper; modal z-index token lifted above the WP admin bar.
  • Row keys are guaranteed on hydration, so open/active state is keyed by item and survives removal and reorder.
  • Opt-in reordering (sortable / PHP sortable): TUI MoveHandle in arrows mode, move dispatcher action, live-region announcements, focus kept on the moved row. No drag library.
  • Row actions: Fields Button gains an icon prop rendering TUI IconButton with tooltip; Clone and Remove carry row-specific names ("Remove item 2"). All repeater copy is overridable through strings.
  • TUI 0.2.20 adopted (PRs #98/#99 were requested from this branch): alertdialog, Modal.Foot layout, MoveHandle mode="arrows" with index, Accordion.Trigger unstyled, nullable controlled accordion value, useAccordionItem.

Layouts

  • Advanced: flush TUI Accordion, chevron IconButton trigger with row-specific tooltip, double-click on the overview row, link-variant always-visible actions, chrome on TUI tokens with animated hover wash.
  • Block: card TUI Accordion of PanelItems (ExpandablePanel rebuilt on the same); title is the trigger, checkbox/switch beside it; actionsPosition="header" (PHP actions_position) moves Clone/Remove into the trigger row as icon buttons.
  • Table: to the design reference; scoped grid styles; outline icon actions; move handle column.
  • Tabs: base Tabs compound, tab field and tab repeater on TUI Tabs (underline); tablist named after the field; neighbour selected after removing the active item.
  • Bare: keyed rows, icon Remove, optional move handle.

Verification

  • Jest: all repeater layouts, ConfirmDialog, accordion/tab fields, dynamic values, conditional panel, dependent values.
  • tsc --noEmit: no new errors versus main (three pre-existing ones removed).
  • Chromium checks via Storybook for each layout: keyboard toggling, arrow navigation, tooltips, focus after delete/move, alertdialog behaviour.
  • Legacy stories gained Sortable / HeaderActions variants.

Follow-ups (not in this PR)

  • headingLevel on accordion rows (PanelItem accepts it; not enabled).
  • TUI: tabpanel tabIndex=0 on every active panel; export useTabsContext.
  • Tab layout calls beforeRow/afterRow per field (pre-existing).

🤖 Generated with Claude Code

juliacanzani and others added 22 commits September 3, 2026 13:54
The repeater's remove / remove-all / bulk-delete confirms used the legacy
react-aria ModalTrigger with "Confirmation" titles and "Are you sure…" body
copy. Replace them with a shared ConfirmDialog built on TUI Modal.

- ConfirmDialog (controlled) and ConfirmTrigger (button + dialog) in base
- Title/body wired as the dialog's accessible name and description
- Initial focus lands on Cancel, the least destructive action
- Escape and backdrop click both cancel
- Confirm button repeats the trigger label unless confirmText is given
- usePortalContainer hook extracted from DynamicFieldSettings so every TUI
  Modal we portal gets the tf-context wrapper; DynamicFieldSettings now
  uses it too, and only mounts the container while open
- Lift --tui-layer-modal on .tf-interface so TUI modals clear the WP admin
  bar (the legacy .tf-modal did this for itself only)
- Repeater strings gain removeAll and confirm title/description copy so
  the dialog text can be overridden like the other labels
- Storybook stories for the dialog; repeater tests target .tf-confirm-dialog

ModalTrigger stays for the conditional panel and the `modal` element.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Edit/Close, Clone and Delete now use TUI's link variant at xs, laid out
as a flex row with a gap (link buttons have no padding of their own).
The hover-only reveal is gone: the actions are always visible and always
in the tab order, which the display:none toggle never allowed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…strings)

Follow-up to 38f14ca from the a11y and code reviews.

- Focus restore on close was broken for every close path: the Modal was
  unmounted instead of re-rendered with open=false, which is the only
  render in which TUI restores focus to the trigger. The same gate in
  usePortalContainer regressed DynamicFieldSettings. The hook now creates
  its wrapper on first activation and keeps it until unmount, and the
  dialog gates only on the container, not on open
- usePortalContainer: useLayoutEffect, el.remove(), fallback wrapper
  includes tf-interface, docblock states the real reasons for the wrapper
- Set --tui-modal-z on .tf-interface instead of lifting the shared
  --tui-layer-modal token that Sidebar and is-layer-modal also read
- Confirm copy follows the same per-layout override as the verb it echoes
  ("Delete this item?" in advanced), and bulk-action copy moves into
  strings.ts so every repeater confirm is overridable the same way
- string(name, params) fills {placeholders}; replaces the inline %d replace
- ModalTrigger marked @deprecated in favour of ConfirmTrigger
- New confirmDialog tests: name/description wiring, initial focus on
  Cancel, focus restore after cancel and confirm, Escape as cancel,
  confirmText override, one wrapper per trigger removed on unmount

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Replaces the hand-rolled collapse (index state, span-as-button chevron,
data-open styling) with a flush single-open TUI Accordion.

- One Accordion.Item per row, keyed by item.key: deleting a row above the
  open one no longer switches which row is open
- The chevron is a TUI IconButton (with tooltip) wrapped by
  Accordion.Trigger asChild, so it carries aria-expanded/aria-controls,
  Enter/Space, and Arrow/Home/End between rows. Labels are row-specific
  ("Open item 2" / "Close item 2") and overridable via strings, as are
  Edit and Close
- Double-click on the overview row toggles it, except on its own controls;
  a mousedown guard stops the double-click selecting text
- Closed rows still render no fields (the field-group tests pin this)
- Flush accordion goes square between the column-label header and the
  footer actions, which keep the outer radius; TUI owns item borders,
  hover and open state
- Repeater/Layouts stories for advanced (with and without bulk), block,
  table and tab, rendered through the public Field entry
- header.tsx docblock named the prop headerConfig; it is headerFields
- Tests: trigger wiring and labels, exactly-once toggle on Space/Enter,
  single-open, double-click on overview vs controls, open row survives
  removing the row above

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
From the a11y audit and code review of 18312ce.

- Rows hydrated from a saved value never received a key, so keyed open
  state only held for rows added in-session and the index fallback hid
  it. initDispatcher now assigns a key to any row without one; the
  layouts key on item.key alone
- Focus handoff after delete: the confirm dialog restores focus to its
  trigger, which a delete destroys with the row. Repeater now hands focus
  to the row occupying the removed position (its toggle when the layout
  has one, else its first control), or to the footer actions when the
  list is empty. Same for Remove all
- Edit/Close link carries aria-expanded and a row-specific name ("Edit
  item 2" / "Close item 2") like the chevron. aria-controls waits on TUI
  exposing the panel id
- Double-click guard leaves header values selectable; only row chrome
  suppresses selection
- SCSS: panel spacing token actually zeroed (the previous comment claimed
  spacers that the padding token had already removed); drop the width
  that duplicated IconButton's own size; drop a dead is-size-sm
- Drop the new Repeater.stories.tsx: the existing "(Legacy)" per-layout
  stories cover more (headerFields, callbacks, maxlength, before/afterRow)
- Tests assert against the panel via aria-controls and getByDisplayValue,
  cover aria-hidden on closed panels, Arrow/End navigation, Edit/Close
  state, key assignment on hydration, and focus after delete/remove all;
  the assertion pinning a double-click on Edit as open+close is gone

Upstream asks sent to TUI: asChild trigger paint class, native-button
asChild activation, controlled single value as string | null, exposing
item ids, and a MoveHandle mode with index and arrows but no drag grip.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
sortable: true (PHP: sortable) swaps the plain row index for a TUI
MoveHandle with the index badge and up/down moves. No drag library: the
grip TUI swaps in on hover is suppressed by CSS and the index stays put,
until TUI ships an index-plus-arrows mode (requested).

- dispatcher: move action (from, to), bounds-checked
- Repeater: renderMoveHandle(i) for layouts, row-specific labels
  ("Reorder item 2", "Move item 2 up"), a polite live region announcing
  "Item moved to position 2 of 3", and focus kept on the moved row's
  same-direction arrow (or the opposite one at a boundary)
- Advanced: index cell hosts the handle when sortable; sortable is only
  honoured when the repeater is repeatable
- Legacy Advanced story gains a Sortable variant
- Tests: plain index when not sortable, labelled handles with boundary
  arrows disabled, move + announcement + focus, focus fallback at the
  end, open row stays open across a move

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…, unstyled trigger

TUI 0.2.20 ships the changes requested from this branch (TUI PRs #98
and #99), so the interim workarounds go.

- ConfirmDialog: role="alertdialog"; backdrop click no longer cancels
  (TUI's default for the role), Escape still does. The docblock note
  waiting on TUI is gone
- Modal.Foot is an end-justified actions row by default: the hand-rolled
  flex rules on the confirm and dynamic-settings footers are removed
- MoveHandle mode="arrows" with index: the CSS that hid the drag grip and
  pinned the badge on hover is removed
- Accordion.Trigger asChild unstyled: the specificity reset against the
  trigger paint class is removed; our data-state rotation rule stays
- Controlled single accordion takes null for "nothing open" instead of ''
- Edit/Close moves into a ToggleLink that reads useAccordionItem() for
  aria-controls and toggle

package-lock also picks up the 2026.8.19 version that was already stale.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Unsortable rows show a 32px muted badge with the same radius, colour and
type as the MoveHandle index, so sortable and unsortable repeaters read
as one family.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…MoveHandle

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ry Add item

- Header, rows, accordion and footer borders/backgrounds/radius/spacing
  move from SCSS variables to TUI tokens: --tui-color-border,
  --tui-color-bg-surface / -muted, --tui-radius-md, --tui-spacing-xs/md,
  --tui-border-width; open-row and hover tint on --tui-theme-primary-subtlest;
  the accent bar and first column on --tui-theme-primary-base
- Hover/open wash transitions background-color on --tui-motion-duration
  and --tui-motion-timing; TUI zeroes the duration under
  prefers-reduced-motion, so the token carries the opt-out
- Add item is a primary outline button (Julia)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ExpandablePanel becomes a thin single-item TUI Accordion, and its row is
exposed as PanelItem so the Block layout renders one card-variant
accordion of PanelItems. The Accordion field keeps its API.

- Title is the real trigger: TUI's own full-width button with the built-in
  chevron, so aria-expanded/aria-controls, Enter/Space and Arrow/Home/End
  come for free. The bulk checkbox and switch sit beside the trigger, not
  inside a button. The header row still toggles on click for pointer
  users, ignoring clicks on its own controls
- Block keys open state by item.key (first row open by default as before)
  and passes PanelItem an always-visible footer for Clone / Edit-Close /
  Remove; Edit-Close is the shared ToggleLink (moved from Advanced) with
  aria-expanded, aria-controls and a row-specific name
- Block supports sortable: the MoveHandle sits in the header's left slot
- behavior='hide' keeps content mounted and lets TUI hide it; 'remove'
  (default) unmounts closed content as before
- Panel and block SCSS on TUI tokens: item border/radius/surface/gap from
  the accordion, footer border and spacing from tokens; the hand-rolled
  chevron, data-status show/hide rules and section mixin are gone.
  Beaver Builder theme drops selectors for the old button header
- focusAfterRemove prefers any [data-tui-accordion-trigger] in the row
- Tests: block rows open/close from trigger, header row and footer link;
  header controls do not toggle; open row survives removal above; move
  handle in the header when sortable. Existing accordion-field and
  dependent-title tests pass against the new markup

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Table layout, following the design reference:
- One bordered TUI-token surface holding header row, hairline-divided
  rows and the footer; borders, radius and spacing from tokens
- Sortable rows lead with a MoveHandle (arrows only, no index badge:
  renderMoveHandle gained an { index } option)
- Bulk checkbox column with a per-row label ("Select item 2"); visually
  hidden header labels for the order, select and actions columns
- Row actions are outline icon buttons: Clone (copy icon, primary) and
  Remove (minus icon, danger). Actions column only when repeatable

Block layout: actionsPosition="header" (PHP: actions_position) puts Clone
and Remove in the trigger row as ghost icon buttons and drops the footer;
Edit/Close is redundant with the trigger there. Default stays 'footer'.

Button: contentVisuallyHidden now renders the label visually hidden
inside the button instead of moving it to aria-label, so icon-only
actions keep their text for find-in-page, translation and tests.

Shared iconAction() helper in repeater/common; strings for the new
labels; Sortable table story and HeaderActions block story; tests reach
buttons through their visually hidden labels; block header-actions test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Shared isInteractiveTarget() in utils/interactive, used by the Block
  header click and the Advanced double-click; covers role=group so a
  click on a MoveHandle's own chrome never toggles a row. PanelItem also
  treats its side slots as control zones in their entirety
- Gesture divergence recorded where it lives: Block headers toggle on a
  single click (card convention), Advanced overview rows on double-click
  (dense, selectable values)
- Layouts receive `repeatable` instead of decoding maxLength === undefined
- Clone and Remove triggers carry row-specific names ("Clone item 2",
  "Remove item 2"; "Duplicate"/"Delete" in advanced) alongside their
  visible labels; bulk checkbox and switch labels go through string()
- PanelItem accepts headingLevel (not yet used by the layouts: the row
  title as a heading is a call for later)
- ExpandablePanel: the .tf-panel wrapper with data-status is documented
  as the public theming hook; the isOpen sync rule is spelled out
- Accordion field drops the stopPropagation wrapper the header guard
  made redundant; its tests no longer mute console.warn
- Elementor theme targets the new trigger instead of the old button
  header; Beaver Builder drops a duplicate footer border
- Tests: uncontrolled accordion content stays mounted but hidden while
  closed, controlled content is removed; Block arrow-key navigation,
  focus handoff after delete, empty left slot, per-row action names

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…econdary

- Fields Button gains an `icon` prop: it renders a TUI IconButton with the
  label as accessible name and tooltip (showTooltip on by default), so the
  repeater's renderAction plumbing is unchanged. Link variant falls back to
  ghost, which IconButton supports
- iconAction() now yields { icon, theme, variant }; the table's row actions
  and Block's header actions use it. Duplicate is secondary, Remove danger
- Table drops the CSS that squared the old text buttons
- Tests find row actions by accessible name ("Clone item 2") via a shared
  actionButtons() helper, since icon buttons carry no text; Block header
  test asserts IconButton classes and themes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Child combinators on .tf-repeater-table-grid keep the header, row and cell
rules off tables that fields render inside a cell, such as the date
picker's calendar.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The base Tabs compound (Container / Header / Title / Content / Row…) now
renders TUI Tabs in the underline variant: a real tablist with roving
focus and arrow keys, aria-selected / aria-controls on each tab, and
panels that TUI hides with aria-hidden + inert. The compound API changes
from isOpen/onPress/isActive per part to a `value` per Title and Content
with the active value and change handler on the Container, which also
names the tablist.

- Tab field: same behaviour, named after its label; uncontrolled mode
  keeps inactive panels mounted (behavior=hide) so hidden inputs submit
- Tab repeater: active tab keyed by item.key, so removing the active item
  shows its neighbour instead of shifting by index; Clone and Remove are
  ghost IconButtons with tooltips beside the Add link; the tablist is
  named "Items" (overridable). No sortable support here: tabs have no
  natural place for a move handle
- Styles on TUI tokens: the header draws one divider across list and
  actions, TUI's underline indicator overlaps it; the hand-rolled active
  bar and data-open styling are gone
- Tests target role="tab" / aria-selected and find icon actions by name

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A border on the header clipped the indicator's 1px overhang under the
header's overflow. The list keeps TUI's divider and the actions continue
the same line, unbroken, to the header's end.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Rows keyed by item.key; Remove only when repeatable, as a ghost danger
  IconButton (trash) with the row-specific name and tooltip
- Sortable: an arrows-only MoveHandle leads the row
- Gaps on TUI tokens; fields flex to fill the row
- Shared repeater styles: footer actions on tokens, and the legacy rule
  that recoloured the last action button is gone (Remove all is already a
  danger button); block margins live in block/index.scss

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- The repeatable:false common test compared text, which icon-only actions
  no longer have, so it could not fail; it now asserts by accessible name
  across all layouts
- Tabs compound: Container provides the active value and tablist label
  through a context; Header names the list from it and Content decides
  what to mount from it, so consumers declare each once. The dead
  data-tf-tablist-label attribute is gone; activationMode is forwarded
- Tab repeater: a missing active key is handled explicitly instead of via
  Math.max, the item actions hide when the list is empty (Clone could
  otherwise clone undefined into a keyed empty row), titles and panels are
  both sliced by maxlength, and the tablist is named after the field
  ("Speakers items") when it has a label
- Tab field drops the `false` sentinel so the Tabs stay controlled
- renderAction: clone accepts buttonProps like delete; a caller-provided
  aria-label wins over the row-specific default; the comment about
  repeatable now states the actual rule (Repeater owns the action, layouts
  own the chrome). Delete accepts restoreFocus:false for layouts whose
  trigger survives the removal; the tab layout uses it, since the shared
  handoff assumed rows and only happened to be a no-op there
- Button: the icon branch warns when it has no name; stale comment fixed
- Tests: neighbour selection after removing a middle / last / only tab,
  focus staying on the tab Remove trigger, tablist naming; bare layout
  gets its own file (icon Remove per row, repeatable gate, move handle)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant