Skip to content

Commit c50707c

Browse files
committed
Keep the remove button inside compact invite tags
1 parent e48eb58 commit c50707c

9 files changed

Lines changed: 80 additions & 45 deletions

File tree

‎apps/sim/platform/AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Use `ActivityRow` for a first-line glyph or avatar, title/detail and centered tr
3030

3131
Measure the actual scroll viewport with `ScrollArea`, or attach `useScrollEdges` directly to a native or Base UI scroll owner. Use the shared 16px mask only on edges with more content, updating after scroll, resize, input, reset or filtering. Insets establish resting layout; they cannot substitute for an edge fade. Home widgets and notification panels use `--ui-widget-max-size` as a ceiling, not a forced height. Keep the dashboard free to scroll and let each overflowing list or schedule scroll independently. Plain tooltips preview at most three lines; retain the complete accessible text. A structured people popover owns its content and uses the dropdown’s popup padding and row insets.
3232

33-
Form dialogs use `Modal` or `MultiStepModal`. Pass a title, content, action labels and handlers. The shared borderless surface positions its title at the logical start and supplies one quiet Cancel/Back. Command search and previews use the same shadow and dismissal layer. Form controls cannot construct or style a form header, footer or Cancel button. Opening a dialog or advancing its step focuses the first available field. Confirmation dialogs focus Cancel, and read-only previews focus Close. A nested dialog handles Escape one layer at a time. While a form is open, the global search shortcut leaves its draft and keyboard focus in place. Closing returns focus to the invoking control. Textareas scroll or grow within their owner and never show a manual resize handle. Recipient fields use the textarea’s height, type and inset. Each email tag occupies one text line with the recent row’s trailing remove action. Comma or Enter commits a draft, pasting a list commits its addresses, and Backspace from an empty draft removes the last tag.
33+
Form dialogs use `Modal` or `MultiStepModal`. Pass a title, content, action labels and handlers. The shared borderless surface positions its title at the logical start and supplies one quiet Cancel/Back. Command search and previews use the same shadow and dismissal layer. Form controls cannot construct or style a form header, footer or Cancel button. Opening a dialog or advancing its step focuses the first available field. Confirmation dialogs focus Cancel, and read-only previews focus Close. A nested dialog handles Escape one layer at a time. While a form is open, the global search shortcut leaves its draft and keyboard focus in place. Closing returns focus to the invoking control. Textareas scroll or grow within their owner and never show a manual resize handle. Recipient fields use the textarea’s height, draft typography and inset. Committed addresses use compact caption text and an always-visible neutral ×, like a panel tab. The remove button sits inside the tag beside its label; long addresses yield space to the button. Comma or Enter commits a draft, pasting a list commits its addresses, and Backspace from an empty draft removes the last tag.
3434

3535
Use `useClipboard` / `CopyButton` for successful copy feedback and `useFeedbackExpiry` for temporary status. Copied indicators and transient notices dwell for four seconds; repeated events restart the timer. Pause an actionable notice during hover or focus and keep its action available in the stack afterward. Icon color changes use `--ui-motion-color` (120ms, zero under reduced motion). Do not put literal timers or CSS durations in product components.
3636

‎apps/sim/platform/checks/settings.spec.ts‎

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ test('form titles start at their content edge and Cancel is consistent across ro
133133
await expect(edit).toBeHidden()
134134
})
135135

136-
test('invite recipients can be typed, pasted, corrected and removed from the keyboard', async ({
136+
test('invite recipients can be typed, pasted, corrected and removed by keyboard or pointer', async ({
137137
page,
138138
context,
139139
}) => {
@@ -154,16 +154,51 @@ test('invite recipients can be typed, pasted, corrected and removed from the key
154154
await expect(remove('second@example.test')).toBeAttached()
155155
await page.keyboard.press('Backspace')
156156
await expect(remove('second@example.test')).toHaveCount(0)
157-
await page.evaluate(() =>
158-
navigator.clipboard.writeText('first@example.test;second@example.test\nthird@example.test,typo')
157+
const longAddress = 'warehouse-reconciliation-and-customer-operations@example.test'
158+
await page.evaluate(
159+
(address) =>
160+
navigator.clipboard.writeText(`first@example.test;${address}\nthird@example.test,typo`),
161+
longAddress
159162
)
160163
await page.keyboard.press('ControlOrMeta+v')
161164
await expect(modal.getByRole('button', { name: /^Remove / })).toHaveCount(4)
162165
await expect(input).toHaveAttribute('aria-invalid', 'true')
163166
await page.keyboard.press('Backspace')
164167
await expect(input).not.toHaveAttribute('aria-invalid', 'true')
165-
await modal.getByText('second@example.test', { exact: true }).hover()
166-
await remove('second@example.test').click()
168+
const longRemove = remove(longAddress)
169+
await longRemove.scrollIntoViewIfNeeded()
170+
await page.mouse.move(0, 0)
171+
const firstRowBottom = await remove('first@example.test').evaluate(
172+
(button) => button.getBoundingClientRect().bottom
173+
)
174+
for (const hover of [false, true]) {
175+
if (hover) await longRemove.hover()
176+
const bounds = await longRemove.evaluate((button, address) => {
177+
let tag = button.parentElement
178+
while (tag && !tag.textContent?.includes(address)) tag = tag.parentElement
179+
if (!tag) throw new Error('Missing tag around the address and its remove button')
180+
const hit = button.getBoundingClientRect()
181+
const box = tag.getBoundingClientRect()
182+
return {
183+
top: hit.top,
184+
insideTag:
185+
hit.left >= box.left &&
186+
hit.right <= box.right &&
187+
hit.top >= box.top &&
188+
hit.bottom <= box.bottom,
189+
visible: button
190+
.querySelector('svg')
191+
?.checkVisibility({ opacityProperty: true, visibilityProperty: true }),
192+
}
193+
}, longAddress)
194+
expect(bounds.top).toBeGreaterThanOrEqual(firstRowBottom)
195+
expect(bounds, hover ? 'Hovered remove action' : 'Resting remove action').toMatchObject({
196+
insideTag: true,
197+
visible: true,
198+
})
199+
}
200+
await longRemove.click()
201+
await expect(longRemove).toHaveCount(0)
167202
await expect(input).toBeFocused()
168203
await page.keyboard.type('draft')
169204
await page.keyboard.press('Backspace')

‎apps/sim/platform/design-system/catalog/variants.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
},
3030
"IconButton": {
3131
"size": {
32+
"inline": [],
3233
"row": [],
3334
"small": [
3435
"C0120",

‎apps/sim/platform/design-system/components/button/button.shared.module.css‎

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@
4343
width: var(--spacing-24);
4444
height: var(--spacing-24);
4545
}
46+
.iconButton[data-size="inline"] {
47+
inline-size: var(--spacing-16);
48+
block-size: var(--spacing-16);
49+
}
4650

4751
.chip {
4852
min-height: var(--spacing-app-chip);
@@ -98,6 +102,10 @@
98102
}
99103

100104
@media (pointer: coarse) {
105+
.iconButton[data-size="inline"] {
106+
inline-size: var(--spacing-20);
107+
block-size: var(--spacing-20);
108+
}
101109
.iconButton[data-size="small"] {
102110
inline-size: var(--spacing-app-touch-target);
103111
block-size: var(--spacing-app-touch-target);
@@ -148,24 +156,6 @@
148156
visibility: hidden;
149157
}
150158

151-
.chip[data-recipient] {
152-
min-block-size: 0;
153-
block-size: 1lh;
154-
background: var(--ui-hover);
155-
cursor: text;
156-
}
157-
.chip[data-recipient="invalid"],
158-
.chipRow:hover > .chip[data-recipient="invalid"] {
159-
background: var(--control-danger-soft);
160-
color: var(--control-danger);
161-
}
162-
.chipRow:has(> .chip[data-recipient]) {
163-
position: relative;
164-
}
165-
.chipRow:has(> .chip[data-recipient]) > .chipAction {
166-
position: absolute;
167-
inset-inline-end: 0;
168-
}
169159
@media (hover: none) {
170160
.chipAction {
171161
opacity: 1;

‎apps/sim/platform/design-system/components/button/button.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ interface IconButtonProps {
6363
render?: ReactElement
6464
label: string
6565
icon: IconName
66-
size?: 'small' | 'row' | 'compact'
66+
size?: 'small' | 'row' | 'compact' | 'inline'
6767
variant?: 'quiet' | 'solid' | 'floating'
6868
disabled?: boolean
6969
onClick?: MouseEventHandler<HTMLButtonElement>

‎apps/sim/platform/design-system/components/button/control-row.tsx‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ interface ControlRowProps {
1616
onClick?: () => void
1717
icon?: IconName
1818
wrap?: boolean
19-
recipient?: 'valid' | 'invalid'
2019
action?: ReactNode
2120
actionSide?: 'start' | 'end'
2221
}
@@ -30,7 +29,6 @@ export function ControlRow({
3029
onClick,
3130
icon,
3231
wrap,
33-
recipient,
3432
action,
3533
actionSide = 'end',
3634
}: ControlRowProps) {
@@ -39,7 +37,6 @@ export function ControlRow({
3937
props: {
4038
className: styles.chip,
4139
'data-type': 'T043',
42-
'data-recipient': recipient,
4340
'aria-label': accessibleLabel,
4441
onClick,
4542
children: (

‎apps/sim/platform/design-system/components/workspace-fields/workspace-fields.module.css‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,22 @@
5050
border-color: var(--control-danger);
5151
}
5252

53+
.tag {
54+
display: inline-flex;
55+
align-items: center;
56+
min-inline-size: 0;
57+
max-inline-size: 100%;
58+
gap: var(--ui-action-gap);
59+
padding-inline: var(--spacing-4) var(--spacing-2);
60+
border-radius: var(--ui-control-radius);
61+
background: var(--ui-hover);
62+
}
63+
64+
.tag[data-invalid] {
65+
background: var(--control-danger-soft);
66+
color: var(--control-danger);
67+
}
68+
5369
.recipientViewport {
5470
display: flex;
5571
flex-wrap: wrap;

‎apps/sim/platform/design-system/components/workspace-fields/workspace-fields.tsx‎

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
import { useId, useLayoutEffect, useRef, useState } from 'react'
44
import { IconButton } from '@/platform/design-system/components/button/button'
5-
import { ControlRow } from '@/platform/design-system/components/button/control-row'
65
import { FieldFrame } from '@/platform/design-system/components/field/fields'
76
import { Icon } from '@/platform/design-system/components/icon/icon'
7+
import { OverflowText } from '@/platform/design-system/components/overflow-text/overflow-text'
88
import { useScrollEdges } from '@/platform/design-system/components/scroll-area/use-scroll-edges'
99
import styles from '@/platform/design-system/components/workspace-fields/workspace-fields.module.css'
1010

@@ -37,21 +37,17 @@ interface RecipientTagProps extends Recipient {
3737

3838
export function RecipientTag({ address, invalid, onRemove }: RecipientTagProps) {
3939
return (
40-
<ControlRow
41-
render={<span />}
42-
label={address}
43-
recipient={invalid ? 'invalid' : 'valid'}
44-
action={
45-
onRemove && (
46-
<IconButton
47-
size='compact'
48-
label={`Remove ${address}`}
49-
icon='I105'
50-
onClick={() => onRemove(address)}
51-
/>
52-
)
53-
}
54-
/>
40+
<span className={styles.tag} data-invalid={invalid || undefined} data-type='T052'>
41+
<OverflowText text={address}>{address}</OverflowText>
42+
{onRemove && (
43+
<IconButton
44+
size='inline'
45+
label={`Remove ${address}`}
46+
icon='I105'
47+
onClick={() => onRemove(address)}
48+
/>
49+
)}
50+
</span>
5551
)
5652
}
5753

‎apps/sim/platform/docs/component-ownership.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Start with the interaction. A different label, icon, data source, theme or scree
88
| Navigate or select a row | ControlRow renders the actual link, tab or button. Chip, IdentityButton, sidebar, recent-items, destination-tabs, tabs and workspace-list compose it. A trailing ellipsis/close or a leading remove share a sibling action target: never nest a second button inside the first. Workspace choices can wrap to fit their name. |
99
| Open a two-line activity or destination | ActivityRow owns the text stack, leading art, hit area and optional trailing content. Home email and attention, the notification inbox and the chat panel launcher supply data. A notification with independent action buttons composes the same noninteractive ActivityBody alongside its actions. Section titles and scroll bounds stay with the containing feature. |
1010
| Choose a value or run a command | Picker owns Select/Combobox behavior, Menu owns actions, nested menus, popovers and date range choices. These share popup and row recipes; retain their distinct ARIA roles and keyboard behavior. CommandPalette owns multi-group search and navigation inside the shared dialog. |
11-
| Edit or toggle | Field owns labeled input/textarea chrome and errors. Platform globals disable manual textarea resizing. SearchField/MenuSearchField own clearable search and filtering. WorkspaceFields owns recipient parsing, editing, validation, removal and logo upload. RecipientTag composes the ControlRow remove action at the textarea’s line height. ComposerInput owns draft typing, paste and submission. Switch owns a boolean, SegmentedControl owns mutually exclusive choices. |
11+
| Edit or toggle | Field owns labeled input/textarea chrome and errors. Platform globals disable manual textarea resizing. SearchField/MenuSearchField own clearable search and filtering. WorkspaceFields owns recipient parsing, editing, validation, removal and logo upload. RecipientTag pairs a bounded address with an always-visible inline IconButton. The field wraps tags as a unit. ComposerInput owns draft typing, paste and submission. Switch owns a boolean, SegmentedControl owns mutually exclusive choices. |
1212
| Display layers and transient feedback | Modal owns title, body, multi-step progression and footer. OverlayDialog supplies the same surface to search and attachment previews. Tooltip owns bounded plain labels; a structured people panel uses its own measured content. Feedback owns clipboard success and timeout behavior. The notification tray owns history and Undo, with one control shadow outside the scroll viewport. |
1313
| Show files, people and media | Attachments composes ControlRow with file glyph or thumbnail, an accessible preview target and an independent quiet remove action. Avatar owns image and single-initial fallbacks across profiles, email and people details. Icon owns interface paint and distinguishes branded artwork. Product callers supply media or data. |
1414
| Read or inspect data | Chart owns shared numerical formatting, point inspection and chart tooltips. DataDisplay owns metrics and table semantics; PropertyList owns labeled values, ExecutionSteps is a noninteractive numbered plan, and EmptyState describes absent data. Calendar hours/events, Home activity chart axes and chat tool payloads remain with the features that interpret them. |

0 commit comments

Comments
 (0)