Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
38f14ca
Add shared ConfirmDialog on TUI Modal, replace repeater confirm modals
juliacanzani Sep 3, 2026
b68f059
Advanced repeater: row actions as always-visible link buttons
juliacanzani Sep 3, 2026
c2e6d49
ConfirmDialog: review fixes (focus restore, z token, verb-consistent …
juliacanzani Sep 3, 2026
18312ce
Advanced repeater: rows on TUI Accordion with chevron IconButton trigger
juliacanzani Sep 3, 2026
8ae9eff
Repeater: review follow-ups to the accordion rework
juliacanzani Sep 3, 2026
a84417d
Repeater: opt-in row reordering with TUI MoveHandle (advanced layout)
juliacanzani Sep 3, 2026
ed09573
Adopt @tangible/ui 0.2.20: alertdialog, Modal.Foot, arrows MoveHandle…
juliacanzani Sep 3, 2026
c89deb8
Advanced repeater: no underline on row action links
juliacanzani Sep 3, 2026
74e491f
Advanced repeater: plain index styled as the TUI MoveHandle badge
juliacanzani Sep 3, 2026
75750d2
Advanced repeater: plain index badge min-height 48px to sit with the …
juliacanzani Sep 3, 2026
11010a6
Advanced repeater: chrome on TUI primitives, animated row wash; prima…
juliacanzani Sep 3, 2026
a8c6738
Block repeater and ExpandablePanel on TUI Accordion
juliacanzani Sep 3, 2026
c2c9472
Table repeater to the design reference; Block header actions option
juliacanzani Sep 3, 2026
879b2d7
Repeater: follow-ups from the Block/PanelItem reviews
juliacanzani Sep 3, 2026
fc3413d
Repeater icon actions on TUI IconButton with tooltips; Duplicate is s…
juliacanzani Sep 3, 2026
4651327
Table repeater: scope grid styles to the repeater's own table
juliacanzani Sep 3, 2026
ae69349
Table repeater: border radius adjustment (Julia)
juliacanzani Sep 3, 2026
5e80583
Tabs on TUI Tabs: base compound, tab field, tab repeater layout
juliacanzani Sep 3, 2026
43f39d3
Tabs: keep the divider on the list so the active indicator shows
juliacanzani Sep 3, 2026
587aac3
Bare repeater: keyed rows, repeatable prop, sortable handle, icon Remove
juliacanzani Sep 3, 2026
10f013a
Bare repeater: Sortable story
juliacanzani Sep 3, 2026
5b3a067
Tabs and Bare: follow-ups from the reviews
juliacanzani Sep 3, 2026
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: 1 addition & 1 deletion assets/build/all.unlayered.min.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion assets/build/beaver-builder/index.min.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion assets/build/default/index.min.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion assets/build/elementor/index.min.css

Large diffs are not rendered by default.

56 changes: 28 additions & 28 deletions assets/build/example.min.js

Large diffs are not rendered by default.

56 changes: 28 additions & 28 deletions assets/build/index.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion assets/build/wp/index.min.css

Large diffs are not rendered by default.

57 changes: 50 additions & 7 deletions assets/src/components/base/button/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { CSSProperties, MouseEvent, ReactNode } from 'react'
import { forwardRef, useEffect, useRef } from 'react'
import type { Ref } from 'react'
import { Button as TuiButton } from '@tangible/ui'
import type { ButtonProps as TuiButtonProps } from '@tangible/ui'
import { Button as TuiButton, IconButton as TuiIconButton } from '@tangible/ui'
import type { ButtonProps as TuiButtonProps, IconButtonProps as TuiIconButtonProps } from '@tangible/ui'

import { isDev } from '../../../utils/is-dev'
import { triggerEvent } from '../../../events'
Expand Down Expand Up @@ -85,6 +85,13 @@ export interface FieldsButtonProps extends ForwardedTuiProps {
'data-test-id'?: string
'aria-label'?: string
contentVisuallyHidden?: boolean
/**
* Icon-only button: renders a TUI IconButton with the label as its
* accessible name and tooltip. Content/children become that label
*/
icon?: TuiIconButtonProps['icon']
showTooltip?: TuiIconButtonProps['showTooltip']
tooltipSide?: TuiIconButtonProps['tooltipSide']
changeTag?: 'button' | 'span'
data?: unknown
wrapper?: unknown
Expand Down Expand Up @@ -139,6 +146,9 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement | HTMLSpanElemen
onClick,
onPress,
contentVisuallyHidden,
icon,
showTooltip,
tooltipSide,
changeTag,
/**
* Destructured to prevent react-aria props from reaching the DOM.
Expand Down Expand Up @@ -182,10 +192,16 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement | HTMLSpanElemen

const mapped = layoutMap[resolvedLayout]
const label: ReactNode = content ?? children
/**
* Two icon-only modes: `contentVisuallyHidden` keeps the label in the DOM,
* visually hidden; `icon` renders a TUI IconButton whose accessible name
* and tooltip are the label
*/
const ariaLabel = ariaLabelProp
const stringLabel = typeof label === 'string' ? label : undefined
const computedAriaLabel = contentVisuallyHidden ? stringLabel : undefined
const ariaLabel = ariaLabelProp ?? computedAriaLabel
const renderedContent = contentVisuallyHidden ? null : label
const renderedContent = contentVisuallyHidden
? <span className="tui-visually-hidden">{label}</span>
: label
const resolvedDisabled = Boolean(disabled || isDisabled)
const rawTheme = theme ?? mapped.theme
const resolvedTheme = rawTheme === 'destructive' ? 'danger' : rawTheme
Expand Down Expand Up @@ -229,15 +245,42 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement | HTMLSpanElemen
if (
!hasWarnedMissingLabel.current &&
isDev() &&
renderedContent == null &&
!ariaLabel
(icon ? !ariaLabel && !stringLabel : renderedContent == null && !ariaLabel)
) {
hasWarnedMissingLabel.current = true
console.warn(
'[Fields Button] Button has no visible label and no aria-label. Provide `content`, `children`, or an accessible label.'
)
}

if (icon) {
return (
<TuiIconButton
/* TUI's ButtonProps is a button-or-anchor union; the leftover props
are plain HTML attributes, so hand them over untyped */
{...(tuiProps as Record<string, unknown>)}
ref={ref as Ref<HTMLButtonElement | HTMLAnchorElement>}
icon={icon}
label={ariaLabel ?? stringLabel ?? ''}
size={resolvedSize}
theme={resolvedTheme}
variant={resolvedVariant === 'link' ? 'ghost' : resolvedVariant}
disabled={resolvedDisabled}
loading={resolvedLoading}
showTooltip={showTooltip ?? true}
tooltipSide={tooltipSide}
/* IconButtonProps is a button|anchor union whose onClick types do not
unify; we always render a button here */
onClick={handleClick as never}
className={className}
style={style}
data-testid={resolvedTestId}
data-test-id={resolvedTestId}
data-name={name}
/>
)
}

if (changeTag === 'span') {
if (!hasWarnedSpan.current && isDev()) {
hasWarnedSpan.current = true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { useState } from 'react'

import ConfirmDialog, { ConfirmTrigger } from './ConfirmDialog'
import Button from '../button/Button'

const meta = {
title: 'Base/ConfirmDialog',
component: ConfirmTrigger,
parameters: {
layout: 'centered'
},
tags: ['autodocs'],
argTypes: {
confirmType: {
control: 'select',
options: ['danger', 'primary']
}
}
} satisfies Meta<typeof ConfirmTrigger>

export default meta
type Story = StoryObj<typeof meta>

export const Destructive: Story = {
args: {
label: 'Remove',
title: 'Remove item 2?',
children: 'This cannot be undone.',
onConfirm: () => console.log('confirmed')
}
}

export const RemoveAll: Story = {
args: {
label: 'Remove all',
title: 'Remove all items?',
children: 'Every item in this list will be removed.',
onConfirm: () => console.log('confirmed')
}
}

export const NonDestructive: Story = {
args: {
label: 'Publish',
title: 'Publish these changes?',
children: 'The changes go live immediately.',
confirmType: 'primary',
buttonProps: { type: 'primary' },
onConfirm: () => console.log('confirmed')
}
}

export const IconTrigger: Story = {
args: {
label: 'Remove',
title: 'Remove item?',
buttonProps: { type: 'icon-trash', contentVisuallyHidden: true },
onConfirm: () => console.log('confirmed')
}
}

export const TitleOnly: Story = {
args: {
label: 'Discard',
title: 'Discard unsaved changes?',
onConfirm: () => console.log('confirmed')
}
}

/**
* The controlled component on its own, for when the trigger is not a button
* (or the confirm is fired from elsewhere in the flow).
*/
export const Controlled: StoryObj = {
render: () => {
const [open, setOpen] = useState(false)
const [log, setLog] = useState('')
return (
<div style={{ display: 'grid', gap: 12, justifyItems: 'center' }}>
<Button type="action" onPress={() => setOpen(true)}>Open dialog</Button>
<ConfirmDialog
open={open}
onOpenChange={setOpen}
title="Remove item?"
confirmText="Remove"
onConfirm={() => setLog('Confirmed')}
onCancel={() => setLog('Cancelled')}
>
Are you sure you want to remove this item?
</ConfirmDialog>
<output>{log}</output>
</div>
)
}
}
165 changes: 165 additions & 0 deletions assets/src/components/base/confirm-dialog/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import type { ReactNode } from 'react'
import { useId, useState } from 'react'
import { Modal } from '@tangible/ui'

import Button from '../button/Button'
import type { FieldsButtonProps } from '../button/Button'
import usePortalContainer from '../modal/usePortalContainer'

type ConfirmType = 'danger' | 'primary'

/**
* Also the initialFocusSelector target and a styling hook in index.scss
*/
export const CANCEL_CLASS = 'tf-confirm-dialog__cancel'

export interface ConfirmDialogProps {
open: boolean
/** Called when the dialog wants to close, whichever way (confirm, cancel, escape, backdrop). */
onOpenChange: (open: boolean) => void
onConfirm: () => void
onCancel?: () => void
title?: ReactNode
children?: ReactNode
confirmText?: ReactNode
cancelText?: ReactNode
/** Visual weight of the confirm action. Destructive by default — that is what a confirm is for. */
confirmType?: ConfirmType
/** Applied to the dialog element, not the trigger (use buttonProps.className for that) */
className?: string
}

/**
* Controlled confirmation dialog on top of TUI Modal.
*
* Accessibility:
* - Title and body are wired as the dialog's name and description
* - Initial focus lands on Cancel — the least destructive action — as
* recommended by the WAI-ARIA dialog pattern for destructive confirms
* - Escape cancels; a backdrop click does nothing, since an alert dialog
* asks for an answer (TUI's default for role="alertdialog")
* - Focus returns to the trigger on close (the Modal stays mounted through
* the close so TUI can restore it)
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/
*/
const ConfirmDialog = ({
open,
onOpenChange,
onConfirm,
onCancel,
title = 'Are you sure?',
children,
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmType = 'danger',
className
}: ConfirmDialogProps) => {

const id = useId()
const titleId = `tf-confirm-title-${id}`
const bodyId = `tf-confirm-body-${id}`

const container = usePortalContainer(open)

const cancel = () => {
onOpenChange(false)
onCancel?.()
}

const confirm = () => {
onOpenChange(false)
onConfirm()
}

if (!container) return null

return (
<Modal
open={open}
onClose={cancel}
role="alertdialog"
size="sm"
container={container}
aria-labelledby={titleId}
aria-describedby={children ? bodyId : undefined}
initialFocusSelector={`.${CANCEL_CLASS}`}
closeOnEscape
className={['tf-confirm-dialog', className].filter(Boolean).join(' ')}
>
<Modal.Head>
<h2 id={titleId} className="tf-confirm-dialog__title">
{title}
</h2>
</Modal.Head>
{children && (
<Modal.Body>
<div id={bodyId} className="tf-confirm-dialog__body">
{children}
</div>
</Modal.Body>
)}
<Modal.Foot className="tf-confirm-dialog__actions">
<Button
type="action"
className={CANCEL_CLASS}
onPress={cancel}
>
{cancelText}
</Button>
<Button
type={confirmType}
className="tf-confirm-dialog__confirm"
onPress={confirm}
>
{confirmText}
</Button>
</Modal.Foot>
</Modal>
)
}

export interface ConfirmTriggerProps extends Omit<ConfirmDialogProps, 'open' | 'onOpenChange'> {
/** Trigger button label. Also the default confirm button label. */
label: ReactNode
buttonProps?: Partial<FieldsButtonProps>
isDisabled?: boolean
}

/**
* Button + ConfirmDialog in one, for the common "press to confirm" case.
* The confirm button repeats the trigger label unless `confirmText` is given,
* so "Remove" opens a dialog whose affirmative action also says "Remove".
*/
const ConfirmTrigger = ({
label,
buttonProps = {},
isDisabled,
confirmText,
...dialogProps
}: ConfirmTriggerProps) => {

const [open, setOpen] = useState(false)

return (
<>
<Button
type="danger"
isDisabled={isDisabled}
{...buttonProps}
onPress={() => setOpen(true)}
>
{label}
</Button>
<ConfirmDialog
open={open}
onOpenChange={setOpen}
confirmText={confirmText ?? label}
{...dialogProps}
/>
</>
)
}

export { ConfirmTrigger }
export default ConfirmDialog
14 changes: 14 additions & 0 deletions assets/src/components/base/confirm-dialog/index.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Confirmation dialog. The shell is TUI Modal; these are the few layout rules
// the compound slots leave to the consumer.
.tf-confirm-dialog {

&__title {
margin: 0;
font-size: var(--tui-font-size-lg, 1.125rem);
line-height: 1.3;
}

&__body {
margin: 0;
}
}
Loading
Loading