From 0088cb37500d98b675053f8f97c364cf0a929bdb Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 29 Sep 2026 10:16:23 -0300 Subject: [PATCH 1/4] refactor(chip): fold ToggleChip into a selected prop ToggleChip was a chip with a checkbox drawn inside it, and its only consumer was Tag. Selection is a state a chip can be in, not a second component, so it becomes a prop. Co-Authored-By: Claude Opus 5 (1M context) --- .../documentation/components/Chip.stories.tsx | 106 +++++++++++------- .../components/ToggleChip.stories.tsx | 49 -------- frontend/web/components/ToggleChip.scss | 20 ---- frontend/web/components/ToggleChip.tsx | 40 ------- frontend/web/components/base/Chip/Chip.scss | 31 +++-- frontend/web/components/base/Chip/Chip.tsx | 26 ++--- frontend/web/project/project-components.js | 3 - 7 files changed, 100 insertions(+), 175 deletions(-) delete mode 100644 frontend/documentation/components/ToggleChip.stories.tsx delete mode 100644 frontend/web/components/ToggleChip.scss delete mode 100644 frontend/web/components/ToggleChip.tsx diff --git a/frontend/documentation/components/Chip.stories.tsx b/frontend/documentation/components/Chip.stories.tsx index 253cf4912963..0d42226e1c30 100644 --- a/frontend/documentation/components/Chip.stories.tsx +++ b/frontend/documentation/components/Chip.stories.tsx @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from 'storybook' import Chip from 'components/base/Chip' import Icon, { IconName } from 'components/icons/Icon' -import Constants from 'common/constants' +import { contentColours } from 'common/theme/tokens' import { getTagSwatchUtilities } from 'components/tags/tagSwatch' const meta: Meta = { @@ -14,7 +14,7 @@ const meta: Meta = { docs: { description: { component: - 'Canonical token-based chip primitive: a small labelled pill token. Layout via Bootstrap utilities, colour/radius via token utilities, padding/sizes/border/truncation in SCSS. Leading/trailing icons go in as children. `none` leaves the colour to the caller, for a decorative colour a user picked rather than a semantic role. Selection lives in ToggleChip and count badges are a separate Badge concern. The legacy `.chip` (old SCSS vars + manual dark-mode block, ~35×) migrates onto this under #6606.', + 'Canonical token-based chip primitive: a small labelled pill token. Layout via Bootstrap utilities, colour/radius via token utilities, padding/sizes/border/truncation in SCSS. Leading/trailing icons go in as children. `none` leaves the colour to the caller, for a decorative colour a user picked rather than a semantic role. `selected` renders a leading checkbox with a tick. The legacy `.chip` (old SCSS vars + manual dark-mode block, ~35×) migrates onto this under #6606.', }, }, layout: 'centered', @@ -31,41 +31,9 @@ export const Accent: Story = { args: { children: '"hello"', variant: 'accent' }, } -export const Sizes: Story = { - render: () => ( -
- Default - Small - Extra small -
- ), -} - -export const Removable: Story = { - args: { children: 'feature-flag', onRemove: () => undefined }, -} - -export const Truncated: Story = { - args: { - children: '{ "test": "testvalue-that-keeps-going-and-going" }', - truncate: true, - variant: 'accent', - }, -} - -export const Group: Story = { - render: () => ( -
- Development - Staging - undefined}>Production -
- ), -} - // How Tag composes Chip: a decorative colour a user picked is not a semantic -// variant, so it arrives as a tag-* swatch class in className. System tags take -// none of it, and carry their state in the icon instead. +// variant, so it arrives as a tag-* swatch class in className. System +// tags take none of it, and carry their state in the icon instead. const SYSTEM_TAGS: { label: string; icon: IconName }[] = [ { icon: 'issue-closed', label: 'Issue closed' }, { icon: 'issue-linked', label: 'Issue open' }, @@ -79,14 +47,14 @@ const SYSTEM_TAGS: { label: string; icon: IconName }[] = [ export const AsSystemTag: Story = { name: 'As a system tag', + parameters: { chromatic: { disableSnapshot: false } }, render: () => (
{SYSTEM_TAGS.map(({ icon, label }) => ( {label} @@ -98,14 +66,14 @@ export const AsSystemTag: Story = { export const AsCustomTag: Story = { name: 'As a custom tag', + parameters: { chromatic: { disableSnapshot: false } }, render: () => (
- {Constants.tagColors.map((colour: string) => ( + {Object.values(contentColours).map((colour) => ( Custom @@ -113,3 +81,57 @@ export const AsCustomTag: Story = {
), } + +/** + * `selected` rings the chip and fades the ones that are not chosen. For a cloud + * of filter chips the chip is the whole control, and anything drawn inside it + * competes with the colour the user picked. See TagFilter. + */ +export const SelectedAsARing: Story = { + name: 'Selected', + render: () => ( +
+ + onboarding + + + analytics + + + billing + +
+ ), +} + +export const Sizes: Story = { + render: () => ( +
+ Default + Small + Extra small +
+ ), +} + +export const Removable: Story = { + args: { children: 'feature-flag', onRemove: () => undefined }, +} + +export const Truncated: Story = { + args: { + children: '{ "test": "testvalue-that-keeps-going-and-going" }', + truncate: true, + variant: 'accent', + }, +} + +export const Group: Story = { + render: () => ( +
+ Development + Staging + undefined}>Production +
+ ), +} diff --git a/frontend/documentation/components/ToggleChip.stories.tsx b/frontend/documentation/components/ToggleChip.stories.tsx deleted file mode 100644 index ab6c7cacc858..000000000000 --- a/frontend/documentation/components/ToggleChip.stories.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React, { useState } from 'react' -import type { Meta, StoryObj } from 'storybook' - -import ToggleChip from 'components/ToggleChip' - -const meta: Meta = { - args: { active: false, children: 'Feature flag' }, - component: ToggleChip, - parameters: { - docs: { - description: { - component: - 'A selectable chip with a leading checkbox. It holds no palette: colour arrives as token utilities in className, so a tag hands it a validated swatch pair rather than a hex to derive from.', - }, - }, - layout: 'centered', - }, - title: 'Components/Data Display/ToggleChip', -} -export default meta - -type Story = StoryObj - -const Interactive = (args: React.ComponentProps) => { - const [active, setActive] = useState(args.active ?? false) - return ( - setActive(!active)} /> - ) -} - -export const Default: Story = { - render: (args: React.ComponentProps) => ( - - ), -} - -export const AllStates: Story = { - parameters: { chromatic: { disableSnapshot: false } }, - render: () => ( -
- Active - Inactive - - Swatch, active - - Swatch -
- ), -} diff --git a/frontend/web/components/ToggleChip.scss b/frontend/web/components/ToggleChip.scss deleted file mode 100644 index 8b8d51432155..000000000000 --- a/frontend/web/components/ToggleChip.scss +++ /dev/null @@ -1,20 +0,0 @@ -// The leading checkbox. Its styles used to come from `.chip .icon-check` in -// _chip.scss, which no longer applies now ToggleChip composes the chip -// primitive, so they live with the component that owns them. -.toggle-chip__check { - width: 14px; - height: 14px; - flex-shrink: 0; - border: 1px solid var(--color-border-default); - border-radius: var(--radius-xs); - - // Checked: the tick supplies the colour, so the box gets out of its way. - &--active { - border-color: transparent; - } - - svg { - width: 14px; - height: 14px; - } -} diff --git a/frontend/web/components/ToggleChip.tsx b/frontend/web/components/ToggleChip.tsx deleted file mode 100644 index ee9b88c4e9ed..000000000000 --- a/frontend/web/components/ToggleChip.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React, { FC, ReactNode } from 'react' -import cx from 'classnames' -import Icon from './icons/Icon' -import Chip from './base/Chip' -import './ToggleChip.scss' - -type ToggleChipProps = { - active?: boolean - onClick?: () => void - className?: string - children?: ReactNode -} - -const ToggleChip: FC = ({ - active, - children, - className, - onClick, -}) => ( - - {/* Without a label this is a bare swatch, e.g. the tag colour picker, where - the box would be noise: the tick alone marks the selection. */} - - {active && } - - {children} - -) - -export default ToggleChip diff --git a/frontend/web/components/base/Chip/Chip.scss b/frontend/web/components/base/Chip/Chip.scss index d027ba601d7d..662535e42606 100644 --- a/frontend/web/components/base/Chip/Chip.scss +++ b/frontend/web/components/base/Chip/Chip.scss @@ -1,9 +1,10 @@ -// Canonical token-based chip. Layout and bg/text colour are utilities in the -// markup; this holds padding, sizes, the variant border colour, truncation and -// the remove-button reset. Distinct class (`ds-chip`) until the legacy `.chip` -// (_chip.scss) migrates here under #6606. +// Padding, sizes, variant border, truncation. Layout and bg/text colour are +// utilities in the markup. `ds-chip` until the legacy `.chip` migrates here. .ds-chip { - padding: 4px 10px; + // 32px from the design system's chip frame. Stated, so an icon-only chip + // matches one holding text. + height: 32px; + padding: 0 10px; font-size: 0.8125rem; white-space: nowrap; // Read from a custom property, not set directly: this stylesheet is injected @@ -18,10 +19,14 @@ // Sizes (default is the base above). &--sm { - padding: 3px 8px; + // Stated now the base has a fixed height, or this would inherit 32px. + // Matches what the padding produced before. + height: 24px; + padding: 0 8px; font-size: 0.75rem; } + // 24px from the design system's tags frame. &--xs { padding: 1px 6px; font-size: 0.6875rem; @@ -43,7 +48,7 @@ white-space: nowrap; } - // Removable close-X: a bare button that inherits the chip's colour. + &__remove { padding: 0; border: 0; @@ -52,3 +57,15 @@ cursor: pointer; } } + +// Two rings: the inner is the page surface, so the outer reads against any fill. +.ds-chip--ring { + box-shadow: + 0 0 0 1px var(--color-surface-default), + 0 0 0 3px var(--color-text-default); +} + +// Faded, so the ring stays the only thing that means selected. +.ds-chip--unselected { + opacity: 0.55; +} diff --git a/frontend/web/components/base/Chip/Chip.tsx b/frontend/web/components/base/Chip/Chip.tsx index 93dee8af5b69..7476ce2f0806 100644 --- a/frontend/web/components/base/Chip/Chip.tsx +++ b/frontend/web/components/base/Chip/Chip.tsx @@ -17,13 +17,13 @@ export type ChipProps = { variant?: ChipVariant size?: ChipSize truncate?: boolean + /** Rings the chip when chosen, fades it when not. Lists mark rows instead: see TagRow. */ + selected?: boolean onRemove?: () => void onClick?: () => void className?: string - // Opt into membership of a keyboard group (e.g. a radiogroup): supply the - // role, roving tabIndex, checked state, key handler and ref. These override - // the button semantics onClick applies by default, so the group owner can - // drive arrow-key navigation. See SdkPicker. + // Membership of a caller-driven keyboard group, overriding the default + // button semantics. See SdkPicker. role?: 'button' | 'radio' tabIndex?: number 'aria-checked'?: boolean @@ -36,16 +36,10 @@ export type ChipProps = { const VARIANT_UTILITIES: Record = { accent: 'bg-surface-action-subtle text-action', neutral: 'bg-surface-subtle text-default', - // No utilities: a caller-supplied colour class would otherwise have to beat - // these on source order alone, which a reordered stylesheet would break. none: '', } -// Token-based chip primitive. Uses `ds-chip` rather than the legacy `.chip` -// (old SCSS vars + a manual `.dark {}` block, ~35 usages) so the two coexist -// until those migrate under #6606. Clickable on its own (role=button), or a -// member of a caller-driven keyboard group via the role/tabIndex/onKeyDown/ref -// props. Count badges are out of scope. +// `ds-chip` rather than the legacy `.chip`, which ~35 components still use. const Chip = ({ 'aria-checked': ariaChecked, 'aria-expanded': ariaExpanded, @@ -56,6 +50,7 @@ const Chip = ({ onRemove, ref, role, + selected, size = 'default', tabIndex, truncate = false, @@ -66,20 +61,23 @@ const Chip = ({ { const { formatOptionLabel } = props.selectProps From 18e72475a6a67f36afa2f95f19f96b8261d135f3 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 29 Sep 2026 10:16:42 -0300 Subject: [PATCH 2/4] feat(tags): give a tag list a row The picker drew each tag as a chip with a checkbox inside it and the actions beside it, so selection and the tag itself were the same object. A row now owns the selection, as a checkmark on the right where the five other lists in the app put it, and the actions collapse into a menu that appears on hover or focus. Tag and TagContent go back to drawing and nothing else: no permission lookups, no plan checks, no colour maths. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/TagRow.stories.tsx | 112 ++++++++++++++++++ .../web/components/tables/TableTagFilter.tsx | 2 +- frontend/web/components/tags/AddEditTags.tsx | 92 +++++++------- frontend/web/components/tags/Tag.tsx | 42 ++----- .../tags/TagColourPicker/TagColourPicker.tsx | 2 +- frontend/web/components/tags/TagContent.tsx | 6 +- frontend/web/components/tags/TagFilter.tsx | 4 +- .../web/components/tags/TagRow/TagRow.scss | 59 +++++++++ .../web/components/tags/TagRow/TagRow.tsx | 80 +++++++++++++ frontend/web/components/tags/TagRow/index.ts | 1 + frontend/web/components/tags/TagValues.tsx | 23 +++- frontend/web/styles/project/_tags.scss | 79 ++---------- 12 files changed, 338 insertions(+), 164 deletions(-) create mode 100644 frontend/documentation/components/TagRow.stories.tsx create mode 100644 frontend/web/components/tags/TagRow/TagRow.scss create mode 100644 frontend/web/components/tags/TagRow/TagRow.tsx create mode 100644 frontend/web/components/tags/TagRow/index.ts diff --git a/frontend/documentation/components/TagRow.stories.tsx b/frontend/documentation/components/TagRow.stories.tsx new file mode 100644 index 000000000000..3b292acfd5b2 --- /dev/null +++ b/frontend/documentation/components/TagRow.stories.tsx @@ -0,0 +1,112 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' +import type { Tag } from 'common/types/responses' + +import TagRow from 'components/tags/TagRow' +import DropdownMenu from 'components/base/DropdownMenu' + +const tag = (over: Partial): Tag => ({ + color: '#c7e6ff', + description: '', + id: 1, + is_permanent: false, + is_system_tag: false, + label: 'billing', + project: 1, + type: 'NONE', + ...over, +}) + +const meta: Meta = { + args: { tag: tag({}) }, + component: TagRow, + parameters: { + chromatic: { disableSnapshot: false }, + docs: { + description: { + component: + 'One tag in a list. Selection is marked on the row rather than inside the chip: a tag is a colour and a name, and whether it is currently picked is a fact about the row. Drawing it inside meant the chip fill ran behind a control that was not part of the tag. The mark is a trailing checkmark, matching every other list in the app: assignees, groups, roles and the table filters.', + }, + }, + layout: 'padded', + }, + title: 'Components/Tags/TagRow', +} +export default meta + +type Story = StoryObj + +/** No mark: a row that shows a tag without offering to select it. */ +export const ReadOnly: Story = { name: 'Not selectable' } + +export const Unchecked: Story = { + args: { checked: false, onToggle: () => undefined }, +} + +export const Checked: Story = { + args: { checked: true, onToggle: () => undefined }, +} + +/** A stale tag on a plan without stale flags. Faded once, not twice. */ +export const Disabled: Story = { + args: { + checked: false, + disabled: true, + onToggle: () => undefined, + tag: tag({ label: 'Stale', type: 'STALE' }), + }, +} + +export const WithUsage: Story = { + args: { checked: false, onToggle: () => undefined, trailing: '3 features' }, + name: 'With a usage count', +} + +export const WithMenu: Story = { + args: { + checked: false, + onToggle: () => undefined, + trailing: ( + undefined }, + { icon: 'trash-2', label: 'Delete', onClick: () => undefined }, + ]} + /> + ), + }, + name: 'With a menu', +} + +/** Selection has to survive a click, which static args cannot show. */ +const List: React.FC = () => { + const [picked, setPicked] = useState([1]) + const tags = [ + tag({ color: '#c7e6ff', id: 1, label: 'billing' }), + tag({ color: '#c7e7e2', id: 2, label: 'analytics' }), + tag({ color: '#d6f1d4', id: 3, label: 'onboarding' }), + tag({ color: '#eff0f3', id: 4, label: 'archived' }), + ] + return ( +
+ {tags.map((t) => ( + + setPicked((p) => + p.includes(t.id) ? p.filter((id) => id !== t.id) : [...p, t.id], + ) + } + tag={t} + trailing={`${t.id} features`} + /> + ))} +
+ ) +} + +export const AList: Story = { + name: 'A list', + render: () => , +} diff --git a/frontend/web/components/tables/TableTagFilter.tsx b/frontend/web/components/tables/TableTagFilter.tsx index 8458ec01083f..4ab148213343 100644 --- a/frontend/web/components/tables/TableTagFilter.tsx +++ b/frontend/web/components/tables/TableTagFilter.tsx @@ -189,7 +189,7 @@ const TableTagFilter: FC = ({ style={{ width: 150 }} className='ml-2 text-nowrap text-overflow' > - +
} diff --git a/frontend/web/components/tags/AddEditTags.tsx b/frontend/web/components/tags/AddEditTags.tsx index 36e24c5fcdb9..493f07d5dea4 100644 --- a/frontend/web/components/tags/AddEditTags.tsx +++ b/frontend/web/components/tags/AddEditTags.tsx @@ -2,7 +2,10 @@ import React, { FC, useEffect, useMemo, useState } from 'react' import { filter as loFilter } from 'lodash' import { useHasPermission } from 'common/providers/Permission' import Utils from 'common/utils/utils' +import { contentColours } from 'common/theme/tokens' import InlineModal from 'components/InlineModal' +import TagRow from './TagRow' +import DropdownMenu from 'components/base/DropdownMenu' import Constants from 'common/constants' import TagValues from './TagValues' import { @@ -15,7 +18,6 @@ import Tag from './Tag' import CreateEditTag from './CreateEditTag' import Input from 'components/base/forms/Input' import Button from 'components/base/forms/Button' -import Icon from 'components/icons/Icon' import TagUsage from 'components/TagUsage' import { ProjectPermission } from 'common/types/permissions.types' @@ -36,21 +38,17 @@ const AddEditTags: FC = ({ projectId, }) - const isFeatureHealthEnabled = Utils.getFlagsmithHasFeature('feature_health') - const unhealthyTagId = useMemo(() => { return data?.find((tag) => tag?.type === 'UNHEALTHY')?.id }, [data]) - const projectTags = useMemo(() => { - if (!isFeatureHealthEnabled) { - return data - } - - return data?.filter( - (projectTag) => !['UNHEALTHY'].includes(projectTag.type), - ) - }, [data, isFeatureHealthEnabled]) + // The unhealthy tag is applied by the system, never picked, so it stays out + // of the list whatever the feature flag says. This used to keep it when the + // flag was off and rely on Tag returning null to hide it again. + const projectTags = useMemo( + () => data?.filter((projectTag) => projectTag.type !== 'UNHEALTHY'), + [data], + ) const [filter, setFilter] = useState('') const [isOpen, setIsOpen] = useState(false) @@ -93,7 +91,7 @@ const AddEditTags: FC = ({
Are you sure you wish to delete the tag{' '}
- +
? This action cannot be undone. @@ -133,8 +131,8 @@ const AddEditTags: FC = ({ }, [filter, projectTags]) const noTags = projectTags && !projectTags.length - const color = - Constants.tagColors[projectTags?.length || 0] || Constants.tagColors[0] + const palette = Object.values(contentColours) + const color = palette[(projectTags?.length || 0) % palette.length] const submit = () => { createTag({ projectId, @@ -150,7 +148,6 @@ const AddEditTags: FC = ({
= ({ showBack={tab !== 'SELECT'} onClose={toggle} className='inline-modal--sm pb-0' + containerClassName='px-0 py-2' bottom={ !readOnly && (
@@ -212,43 +210,37 @@ const AddEditTags: FC = ({
)} -
+
{filteredTags && filteredTags.map((tag) => ( -
- - - editTag(tag), + }, + { + className: 'text-danger', + icon: 'trash-2', + label: 'Delete', + onClick: () => confirmDeleteTag(tag), + }, + ]} /> - - {!readOnly && - !!createEditTagPermission && - !tag.is_system_tag && ( - <> -
editTag(tag)} - className={ - !readOnly - ? 'clickable' - : 'opacity-0 pointer-events-none' - } - > - -
-
confirmDeleteTag(tag)} - className='ml-3 clickable' - > - -
- - )} -
-
+ ) + } + /> ))} {!!filter && !exactTag ? (
void + // Partial, because `tag` is: the filter renders an "Untagged" pseudo-tag + // with no id, and the create row previews a tag that does not exist yet. + onClick?: (tag: Partial) => void selected?: boolean tag: Partial isDot?: boolean @@ -32,8 +34,7 @@ export const getTagColor = (tag: Partial) => { const Tag: FC = ({ className, - disabled, - hideNames, + disabled = false, isDot, onClick, selected, @@ -49,30 +50,6 @@ const Tag: FC = ({ ) } - if (!hideNames && !!onClick) { - return ( - { - if (!disabled) { - onClick?.(tag as TTag) - } - }} - > - {!!tag.label && } - - ) - } - - // Hide unhealthy tags if feature is disabled - if ( - !Utils.getFlagsmithHasFeature('feature_health') && - tag.type === 'UNHEALTHY' - ) { - return null - } - const isSystem = isSystemTag(tag) return ( @@ -87,8 +64,9 @@ const Tag: FC = ({ { 'opacity-50': disabled }, className, )} - onClick={disabled || !onClick ? undefined : () => onClick(tag as TTag)} - size='xs' + onClick={disabled || !onClick ? undefined : () => onClick(tag)} + selected={selected} + size='sm' variant='none' > diff --git a/frontend/web/components/tags/TagColourPicker/TagColourPicker.tsx b/frontend/web/components/tags/TagColourPicker/TagColourPicker.tsx index 6f84d384b611..f8e72e55e417 100644 --- a/frontend/web/components/tags/TagColourPicker/TagColourPicker.tsx +++ b/frontend/web/components/tags/TagColourPicker/TagColourPicker.tsx @@ -19,7 +19,7 @@ const TagColourPicker: FC = ({ {Constants.tagColors.map((colour: string) => ( onChange(tag.color)} + onClick={() => onChange(colour)} selected={value === colour} tag={{ color: colour }} /> diff --git a/frontend/web/components/tags/TagContent.tsx b/frontend/web/components/tags/TagContent.tsx index f495e5a8b74e..0c8cf6d0a8c3 100644 --- a/frontend/web/components/tags/TagContent.tsx +++ b/frontend/web/components/tags/TagContent.tsx @@ -11,8 +11,8 @@ import { isSystemTag, } from './tagSwatch' type TagContent = { - tag: Partial disabled?: boolean + tag: Partial } // Numeric-entity everything that is not alphanumeric or beyond Latin-1. // Stated as the characters it keeps rather than the four ranges it escaped: @@ -54,7 +54,7 @@ const renderIcon = ( } } -const getTooltip = (tag: TTag | undefined, disabled?: boolean) => { +const getTooltip = (tag: TTag | undefined, disabled: boolean) => { if (!tag) { return null } @@ -100,7 +100,7 @@ const getTooltip = (tag: TTag | undefined, disabled?: boolean) => { return tooltip } -const TagContent: FC = ({ disabled, tag }) => { +const TagContent: FC = ({ disabled = false, tag }) => { const tagLabel = Format.truncateText(tag.label, 12) if (!tagLabel) { diff --git a/frontend/web/components/tags/TagFilter.tsx b/frontend/web/components/tags/TagFilter.tsx index 6cd6670e9c49..3938cbc59536 100644 --- a/frontend/web/components/tags/TagFilter.tsx +++ b/frontend/web/components/tags/TagFilter.tsx @@ -87,7 +87,7 @@ const TagFilter: FC = ({ onSelect(tag as TTag)} className='px-2 py-2' tag={unTagged as any} /> @@ -98,7 +98,7 @@ const TagFilter: FC = ({ onSelect(tag as TTag)} className='px-2 py-2 mr-1' tag={tag} /> diff --git a/frontend/web/components/tags/TagRow/TagRow.scss b/frontend/web/components/tags/TagRow/TagRow.scss new file mode 100644 index 000000000000..00c561331e2b --- /dev/null +++ b/frontend/web/components/tags/TagRow/TagRow.scss @@ -0,0 +1,59 @@ +.tag-row { + + padding: 6px 14px; + + &--selectable { + cursor: pointer; + + &:hover { + background-color: var(--color-surface-hover); + } + + &:focus-visible { + outline: 2px solid var(--color-border-action); + outline-offset: -2px; + } + } + + &--disabled { + cursor: not-allowed; + } +} + +// Grow in from the right edge. Opacity and width, never display:none, so the +// button stays in the tab order. +.tag-row__spacer { + flex: 1; +} + +.tag-row__mark { + flex: none; +} + +.tag-row__actions { + display: flex; + align-items: center; + flex: none; + width: 0; + overflow: hidden; + opacity: 0; + color: var(--color-text-secondary); + transition: width var(--duration-fast) var(--easing-standard), + opacity var(--duration-fast) var(--easing-standard), + margin-left var(--duration-fast) var(--easing-standard); +} + +.tag-row:hover .tag-row__actions, +// :has(:focus-visible), not :focus-within: a row keeps DOM focus after a mouse +// click, which left the menu showing once the pointer had gone. +.tag-row:has(:focus-visible) .tag-row__actions { + width: 20px; + margin-left: 8px; + opacity: 1; +} + +@media (prefers-reduced-motion: reduce) { + .tag-row__actions { + transition: none; + } +} diff --git a/frontend/web/components/tags/TagRow/TagRow.tsx b/frontend/web/components/tags/TagRow/TagRow.tsx new file mode 100644 index 000000000000..d8483de5dd67 --- /dev/null +++ b/frontend/web/components/tags/TagRow/TagRow.tsx @@ -0,0 +1,80 @@ +import React, { FC, ReactNode } from 'react' +import classNames from 'classnames' +import { Tag as TTag } from 'common/types/responses' +import Tag from 'components/tags/Tag' +import Icon from 'components/icons/Icon' +import './TagRow.scss' + +type TagRowProps = { + /** Omitted when the row is not selectable. */ + checked?: boolean + disabled?: boolean + onToggle?: (tag: TTag) => void + tag: TTag + /** Whatever the list wants on the right: a menu, a usage count. */ + trailing?: ReactNode +} + +/** + * One tag in a list of tags. Marked with a trailing checkmark, as every other + * list in the app marks selection. Anything in `trailing` appears on hover and + * on focus, taking the right edge, with the mark sliding left to make room. + */ +const TagRow: FC = ({ + checked, + disabled, + onToggle, + tag, + trailing, +}) => { + const selectable = checked !== undefined && !!onToggle && !disabled + const toggle = () => selectable && onToggle(tag) + + return ( +
{ + // Arrow keys walk the list; a list of rows is one thing to move + // through, not one tab stop per row. + const step = + e.key === 'ArrowDown' + ? 'nextElementSibling' + : 'previousElementSibling' + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + const next = e.currentTarget[step] + if (next instanceof HTMLElement) { + e.preventDefault() + next.focus() + } + return + } + if (!selectable) return + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + toggle() + } + }} + role={selectable ? 'checkbox' : undefined} + aria-checked={selectable ? checked : undefined} + tabIndex={selectable ? 0 : undefined} + > + + + {checked !== undefined && ( + + )} + {trailing &&
{trailing}
} +
+ ) +} + +export default TagRow diff --git a/frontend/web/components/tags/TagRow/index.ts b/frontend/web/components/tags/TagRow/index.ts new file mode 100644 index 000000000000..3a096fd5caa8 --- /dev/null +++ b/frontend/web/components/tags/TagRow/index.ts @@ -0,0 +1 @@ +export { default } from './TagRow' diff --git a/frontend/web/components/tags/TagValues.tsx b/frontend/web/components/tags/TagValues.tsx index e8a4029ac459..fa96f2abd1e1 100644 --- a/frontend/web/components/tags/TagValues.tsx +++ b/frontend/web/components/tags/TagValues.tsx @@ -16,13 +16,11 @@ type TagValuesType = { projectId: string children?: ReactNode inline?: boolean - hideNames?: boolean hideTags?: number[] } const TagValues: FC = ({ children, - hideNames = true, hideTags = [], inline, onAdd, @@ -33,7 +31,19 @@ const TagValues: FC = ({ const { data } = useGetTagsQuery({ projectId }) const Wrapper = inline ? Fragment : Row - const tags = data?.filter((tag) => !hideTags?.includes(tag.id)) + // Feature health is a paid feature, and its tag is applied by the system + // rather than chosen, so with the feature off the tag should not appear at + // all. Filtered here rather than inside Tag, which has no business knowing + // about feature flags: this is the display path for ProjectFeatureRow, + // FeatureOverrideRow, FeatureTags, ReleaseManagerPage and + // FlagEnvironmentsPage. + const isFeatureHealthEnabled = Utils.getFlagsmithHasFeature('feature_health') + + const tags = data?.filter( + (tag) => + !hideTags?.includes(tag.id) && + (isFeatureHealthEnabled || tag.type !== 'UNHEALTHY'), + ) const { permission: createEditTagPermission } = useHasPermission({ id: projectId, @@ -48,10 +58,11 @@ const TagValues: FC = ({ (tag) => value?.includes(tag.id) && ( (onAdd ?? onClick)?.(tag)} tag={tag} /> ), diff --git a/frontend/web/styles/project/_tags.scss b/frontend/web/styles/project/_tags.scss index 9bdb0851eb76..2d16af41886b 100644 --- a/frontend/web/styles/project/_tags.scss +++ b/frontend/web/styles/project/_tags.scss @@ -1,88 +1,29 @@ -.tag { - line-height: 18px; - border-radius: $border-radius; - color: white; - font-weight: bold; - transition: all 300ms; - cursor: pointer; - &--select { - .chip-icon { - margin-left: 5px; - } - width: 40px; - line-height: 32px; +// The `.tag` class this file was built around is gone: Tag renders Chip, which +// is `.ds-chip`, and its shape, colour and sizing come from tokens and the chip +// primitive. What is left is layout for the places tags are arranged, plus the +// dot variant, which is not a chip at all. - user-select: none; - -moz-user-select: none; - -ms-user-select: none; - -webkit-user-select: none; - .chip { - margin: 0 !important; - height: 34px; - justify-content: center; - } - } - &-values { - row-gap: 8px; - } +.tag-values { + row-gap: 8px; } + .tag-filter { .tag-filter-list { gap: 8px; } - .tag { - opacity: 0.75; - min-width: 100px; - text-align: center; - .flex-row { - justify-content: center; - } - padding-bottom: 0.2em !important; - padding-top: 0.2em !important; - padding-left: 1em !important; - padding-right: 1em !important; - &.selected { - opacity: 1; - } - .icon { - margin-left: 5px; - } - } -} -.tag-values { - .tag { - margin-right: 0.5em; - padding-bottom: 0 !important; - padding-top: 0 !important; - height: 21px; - width: 42px; - padding-left: 0 !important; - padding-right: 0 !important; - .icon { - display: none; - } - } } + .tag-list { max-height: 230px; overflow-y: auto; user-select: none; - margin-right: -1.5rem; - padding-right: 1.5rem; - -moz-user-select: none; -ms-user-select: none; -webkit-user-select: none; - &:last-child { - } -} - -.dark { - .tag { - border-color: transparent; - } } +// A colour with no label, used where a tag has to be identified in a space too +// small for its name. .tag--dot { width: 16px; height: 16px; From 17e41af091ffe3b8d234edc98f05f0e89fc11657 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 29 Sep 2026 10:16:42 -0300 Subject: [PATCH 3/4] fix(menu): keep a click on a menu item inside its own panel The menu is drawn through a portal on the body, so it is not a descendant of whatever opened it. Anything watching for a click outside itself, the InlineModal holding the tag list, say, counted a click on a menu item as one and closed: picking Edit opened the form and shut it again a moment later, because that watcher defers its close by 100ms. The event now stops at the menu's root. A native listener, not React's onMouseUp, since React's sit below document and the watchers are on document itself. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/web/components/base/DropdownMenu.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/frontend/web/components/base/DropdownMenu.tsx b/frontend/web/components/base/DropdownMenu.tsx index 8c5daed73556..6195914ce8d3 100644 --- a/frontend/web/components/base/DropdownMenu.tsx +++ b/frontend/web/components/base/DropdownMenu.tsx @@ -1,4 +1,4 @@ -import React, { useLayoutEffect, useRef, useState } from 'react' +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react' import Icon, { IconName } from 'components/icons/Icon' import classNames from 'classnames' import useOutsideClick from 'common/useOutsideClick' @@ -37,6 +37,24 @@ const DropdownMenu: React.FC = ({ const dropDownRef = useRef(null) useOutsideClick(dropDownRef, () => setIsOpen(false)) + // The menu is portalled to the body, so anything else watching for a click + // outside itself counts a click in here as one: an InlineModal holding this + // menu would close on the very item you picked. The menu belongs to its + // trigger wherever it is drawn, so the event stops at its root. Native + // rather than React's onMouseUp, because those listeners sit below document, + // which is where the outside-click watchers are. + useEffect(() => { + const node = dropDownRef.current + if (!isOpen || !node) return + const stop = (e: Event) => e.stopPropagation() + node.addEventListener('mouseup', stop) + node.addEventListener('touchend', stop) + return () => { + node.removeEventListener('mouseup', stop) + node.removeEventListener('touchend', stop) + } + }, [isOpen]) + useLayoutEffect(() => { if (!isOpen || !dropDownRef.current || !btnRef.current) return const listPosition = calculateListPosition( From 5371782cb7234139bd3921e523a7548cd3d1d37f Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 25 Sep 2026 13:50:52 -0300 Subject: [PATCH 4/4] refactor(tags): move VCSProviderTag out of the tags folder It is not a tag. It does not use Tag or Chip, it renders its own markup with a count and a provider icon, and it sits in tags/ by name alone. Its three callers are split between feature-summary and feature-page, so it goes to the top level with the other shared components rather than into either. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/documentation/components/VCSProviderTag.stories.tsx | 2 +- frontend/web/components/{tags => }/VCSProviderTag.tsx | 4 ++-- .../CodeReferences/components/RepoSectionHeader.tsx | 2 +- frontend/web/components/feature-summary/FeatureTags.tsx | 2 +- frontend/web/components/feature-summary/ProjectFeatureRow.tsx | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) rename frontend/web/components/{tags => }/VCSProviderTag.tsx (91%) diff --git a/frontend/documentation/components/VCSProviderTag.stories.tsx b/frontend/documentation/components/VCSProviderTag.stories.tsx index ad9b4fc08d67..5d106320f88a 100644 --- a/frontend/documentation/components/VCSProviderTag.stories.tsx +++ b/frontend/documentation/components/VCSProviderTag.stories.tsx @@ -1,7 +1,7 @@ import React from 'react' import type { Meta, StoryObj } from 'storybook' -import VCSProviderTag from 'components/tags/VCSProviderTag' +import VCSProviderTag from 'components/VCSProviderTag' import { VCSProvider } from 'common/types/responses' const meta: Meta = { diff --git a/frontend/web/components/tags/VCSProviderTag.tsx b/frontend/web/components/VCSProviderTag.tsx similarity index 91% rename from frontend/web/components/tags/VCSProviderTag.tsx rename to frontend/web/components/VCSProviderTag.tsx index 46aef8dccdf6..c168aaad7ae1 100644 --- a/frontend/web/components/tags/VCSProviderTag.tsx +++ b/frontend/web/components/VCSProviderTag.tsx @@ -1,6 +1,6 @@ import React from 'react' -import { GithubIcon } from 'components/icons/GithubIcon' -import GitlabIcon from 'components/icons/GitlabIcon' +import { GithubIcon } from './icons/GithubIcon' +import GitlabIcon from './icons/GitlabIcon' import { VCSProvider } from 'common/types/responses' interface VCSProviderTagProps { diff --git a/frontend/web/components/feature-page/FeatureNavTab/CodeReferences/components/RepoSectionHeader.tsx b/frontend/web/components/feature-page/FeatureNavTab/CodeReferences/components/RepoSectionHeader.tsx index 438a2e5e8da9..c68afb236698 100644 --- a/frontend/web/components/feature-page/FeatureNavTab/CodeReferences/components/RepoSectionHeader.tsx +++ b/frontend/web/components/feature-page/FeatureNavTab/CodeReferences/components/RepoSectionHeader.tsx @@ -1,5 +1,5 @@ import Icon from 'components/icons/Icon' -import VCSProviderTag from 'components/tags/VCSProviderTag' +import VCSProviderTag from 'components/VCSProviderTag' import { getDarkMode } from 'project/darkMode' import React from 'react' import { VCSProvider } from 'common/types/responses' diff --git a/frontend/web/components/feature-summary/FeatureTags.tsx b/frontend/web/components/feature-summary/FeatureTags.tsx index 626830db5a7b..75d2cb9bdf11 100644 --- a/frontend/web/components/feature-summary/FeatureTags.tsx +++ b/frontend/web/components/feature-summary/FeatureTags.tsx @@ -9,7 +9,7 @@ import StaleFlagWarning from './StaleFlagWarning' import Tag from 'components/tags/Tag' import Utils from 'common/utils/utils' import { useGetHealthEventsQuery } from 'common/services/useHealthEvents' -import VCSProviderTag from 'components/tags/VCSProviderTag' +import VCSProviderTag from 'components/VCSProviderTag' type FeatureTagsType = { editFeature: (tab?: string) => void diff --git a/frontend/web/components/feature-summary/ProjectFeatureRow.tsx b/frontend/web/components/feature-summary/ProjectFeatureRow.tsx index 3e4c32fc4b35..314361faa15a 100644 --- a/frontend/web/components/feature-summary/ProjectFeatureRow.tsx +++ b/frontend/web/components/feature-summary/ProjectFeatureRow.tsx @@ -4,7 +4,7 @@ import { ProjectFlag, VCSProvider } from 'common/types/responses' import FeatureName from './FeatureName' import FeatureDescription from './FeatureDescription' import TagValues from 'components/tags/TagValues' -import VCSProviderTag from 'components/tags/VCSProviderTag' +import VCSProviderTag from 'components/VCSProviderTag' interface ProjectFeatureRowProps { projectFlag: ProjectFlag