diff --git a/.changeset/public-facts-boil.md b/.changeset/public-facts-boil.md new file mode 100644 index 00000000000..437a593971d --- /dev/null +++ b/.changeset/public-facts-boil.md @@ -0,0 +1,38 @@ +--- +"@spectrum-css/taggroup": major +--- + +The Spectrum 2 version of Tag Group is a major change from its Spectrum 1 counterpart. + +Major style changes include: + +- Use of new tokens and custom properties for spacing tags. The method of spacing between tags has changed. Where previously tags were spaced using tokens to represent inline and block margins on each tag, tags are now spaced by tokens representing the gaps between tags. +- Rather than being a single size, Tag group now comes in t-shirt sizes: Small, medium, and large. These sizes should determine the sizes of the embedded components, but also the spacing between tags. +- Tag group can now accommodate a side label. To do so, it makes use of a grid layout. +- In order to match the layout in the spec, more embedded components besides Tag have been added. Field label, Help text, and Action button (quiet) components have been added to the Storybook implementation, and styles are set for these embedded components within the tag group layout. + +In order to support the aforementioned spacing changes, the two mod properties for margin have been removed: + +- `--mod-tag-group-item-margin-block` +- `--mod-tag-group-item-margin-inline` + +Instead, please customize spacing between tags with: + +- `--mod-tag-group-block-tag-spacing` +- `--mod-tag-group-inline-tag-spacing` + +These custom properties may need to be set to be double the previous margin values in order to achieve the same spacing. + +To support custom spacing of the embedded components, several other new mod properties have been added: + +- `--mod-tag-group-block-spacing-label-to-tags` +- `--mod-tag-group-inline-spacing-label-to-tags` +- `--mod-tag-group-spacing-help-text-to-tags` + +To support the optional empty state (when there are no tags in the tag group), several passthroughs to modify the body typography text element have been added, including: + +- `--mod-body-cjk-line-height` +- `--mod-body-font-size` +- `--mod-body-line-height` +- `--mod-body-margin-end` +- `--mod-body-margin-start` diff --git a/components/taggroup/dist/metadata.json b/components/taggroup/dist/metadata.json index b9eeaede1e8..28ca5140d81 100644 --- a/components/taggroup/dist/metadata.json +++ b/components/taggroup/dist/metadata.json @@ -1,15 +1,58 @@ { "sourceFile": "index.css", - "selectors": [".spectrum-TagGroup", ".spectrum-TagGroup-item"], + "selectors": [ + ".spectrum-TagGroup", + ".spectrum-TagGroup--sideLabel", + ".spectrum-TagGroup--sideLabel .spectrum-TagGroup-actionButton", + ".spectrum-TagGroup--sideLabel .spectrum-TagGroup-helpText", + ".spectrum-TagGroup--sideLabel .spectrum-TagGroup-label", + ".spectrum-TagGroup--sideLabel .spectrum-TagGroup-tags", + ".spectrum-TagGroup--sizeL", + ".spectrum-TagGroup--sizeS", + ".spectrum-TagGroup-actionButton", + ".spectrum-TagGroup-helpText", + ".spectrum-TagGroup-label", + ".spectrum-TagGroup-tags", + ".spectrum-TagGroup:lang(ja)", + ".spectrum-TagGroup:lang(ko)", + ".spectrum-TagGroup:lang(zh)" + ], "modifiers": [ - "--mod-tag-group-item-margin-block", - "--mod-tag-group-item-margin-inline" + "--mod-body-cjk-line-height", + "--mod-body-font-size", + "--mod-body-line-height", + "--mod-body-margin-end", + "--mod-body-margin-start", + "--mod-tag-group-block-spacing-label-to-tags", + "--mod-tag-group-block-tag-spacing", + "--mod-tag-group-inline-spacing-label-to-tags", + "--mod-tag-group-inline-tag-spacing", + "--mod-tag-group-spacing-help-text-to-tags" ], "component": [ - "--spectrum-tag-group-item-margin-block", - "--spectrum-tag-group-item-margin-inline" + "--spectrum-tag-group-block-spacing-label-to-tags", + "--spectrum-tag-group-block-tag-spacing", + "--spectrum-tag-group-inline-spacing-label-to-tags", + "--spectrum-tag-group-inline-tag-spacing", + "--spectrum-tag-group-spacing-help-text-to-tags" + ], + "global": [ + "--spectrum-cjk-line-height-100", + "--spectrum-component-bottom-to-text-100", + "--spectrum-component-bottom-to-text-200", + "--spectrum-component-bottom-to-text-75", + "--spectrum-component-top-to-text-100", + "--spectrum-component-top-to-text-200", + "--spectrum-component-top-to-text-75", + "--spectrum-field-label-to-component", + "--spectrum-font-size-100", + "--spectrum-font-size-200", + "--spectrum-font-size-75", + "--spectrum-help-text-to-component", + "--spectrum-line-height-100", + "--spectrum-spacing-100", + "--spectrum-spacing-200" ], - "global": ["--spectrum-spacing-75"], "passthroughs": [], "high-contrast": [] } diff --git a/components/taggroup/index.css b/components/taggroup/index.css index e3b323bb91f..6975c5186ba 100644 --- a/components/taggroup/index.css +++ b/components/taggroup/index.css @@ -1,5 +1,5 @@ /*! - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2025 Adobe. All rights reserved. * * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy @@ -12,17 +12,93 @@ */ .spectrum-TagGroup { - --spectrum-tag-group-item-margin-block: var(--spectrum-spacing-75); - --spectrum-tag-group-item-margin-inline: var(--spectrum-spacing-75); + --spectrum-tag-group-inline-tag-spacing: var(--spectrum-spacing-200); + --spectrum-tag-group-block-tag-spacing: var(--spectrum-spacing-200); + --spectrum-tag-group-block-spacing-label-to-tags: var(--spectrum-field-label-to-component); + --spectrum-tag-group-inline-spacing-label-to-tags: var(--spectrum-spacing-200); + --spectrum-tag-group-spacing-help-text-to-tags: var(--spectrum-help-text-to-component); + /* passthroughs for body typography element in empty state */ + --mod-body-line-height: var(--spectrum-line-height-100); + --mod-body-font-size: var(--spectrum-font-size-100); + --mod-body-margin-start: var(--spectrum-component-top-to-text-100); + --mod-body-margin-end: var(--spectrum-component-bottom-to-text-100); + + &:lang(ja), + &:lang(zh), + &:lang(ko) { + --mod-body-cjk-line-height: var(--spectrum-cjk-line-height-100); + } +} + +.spectrum-TagGroup--sizeS { + --spectrum-tag-group-inline-tag-spacing: var(--spectrum-spacing-100); + --spectrum-tag-group-block-tag-spacing: var(--spectrum-spacing-100); + + /* passthroughs for body typography element in empty state */ + --mod-body-font-size: var(--spectrum-font-size-75); + --mod-body-margin-start: var(--spectrum-component-top-to-text-75); + --mod-body-margin-end: var(--spectrum-component-bottom-to-text-75); +} + +.spectrum-TagGroup--sizeL { + --spectrum-tag-group-inline-tag-spacing: var(--spectrum-spacing-200); + --spectrum-tag-group-block-tag-spacing: var(--spectrum-spacing-200); + + /* passthroughs for body typography element in empty state */ + --mod-body-font-size: var(--spectrum-font-size-200); + --mod-body-margin-start: var(--spectrum-component-top-to-text-200); + --mod-body-margin-end: var(--spectrum-component-bottom-to-text-200); +} + +.spectrum-TagGroup { + display: grid; + grid-template-rows: auto auto auto auto; +} + +.spectrum-TagGroup-tags { display: inline-flex; flex-wrap: wrap; - margin: 0; - padding: 0; - list-style: none; + column-gap: var(--mod-tag-group-inline-tag-spacing, var(--spectrum-tag-group-inline-tag-spacing)); + row-gap: var(--mod-tag-group-block-tag-spacing, var(--spectrum-tag-group-block-tag-spacing)); + margin-block-end: var(--mod-tag-group-block-tag-spacing, var(--spectrum-tag-group-block-tag-spacing)); +} + +.spectrum-TagGroup-label { + margin-block-end: var(--mod-tag-group-block-spacing-label-to-tags, var(--spectrum-tag-group-block-spacing-label-to-tags)); } -.spectrum-TagGroup-item { - margin-block: var(--mod-tag-group-item-margin-block, var(--spectrum-tag-group-item-margin-block)); - margin-inline: var(--mod-tag-group-item-margin-inline, var(--spectrum-tag-group-item-margin-inline)); +.spectrum-TagGroup-actionButton { + justify-self: start; +} + +.spectrum-TagGroup-helpText { + margin-block-start: var(--mod-tag-group-spacing-help-text-to-tags, var(--spectrum-tag-group-spacing-help-text-to-tags)); +} + +.spectrum-TagGroup--sideLabel { + grid-template-columns: auto auto; + grid-template-rows: auto auto auto; + + .spectrum-TagGroup-label { + grid-row: 1; + grid-column: 1; + margin-block-end: 0; + margin-inline-end: var(--mod-tag-group-inline-spacing-label-to-tags, var(--spectrum-tag-group-inline-spacing-label-to-tags)); + } + + .spectrum-TagGroup-tags { + grid-row: 1; + grid-column: 2; + } + + .spectrum-TagGroup-actionButton { + grid-row: 2; + grid-column: 2; + } + + .spectrum-TagGroup-helpText { + grid-row: 3; + grid-column: 2; + } } diff --git a/components/taggroup/stories/taggroup.stories.js b/components/taggroup/stories/taggroup.stories.js index 66ef23e885d..0a10a82c09e 100644 --- a/components/taggroup/stories/taggroup.stories.js +++ b/components/taggroup/stories/taggroup.stories.js @@ -1,8 +1,10 @@ +import { Sizes, withDownStateDimensionCapture } from "@spectrum-css/preview/decorators"; import { disableDefaultModes } from "@spectrum-css/preview/modes"; +import { isInvalid } from "@spectrum-css/preview/types"; import { default as TagStories } from "@spectrum-css/tag/stories/tag.stories.js"; import metadata from "../dist/metadata.json"; import packageJson from "../package.json"; -import { TagGroups } from "./taggroup.test.js"; +import { exampleTagItems, TagGroupDisabledItemAndActionButton, TagGroups, TagGroupSizingTemplate } from "./taggroup.test.js"; import { Template } from "./template.js"; const ignoreProps = ["rootClass", "hasClearButton", "label"]; @@ -23,31 +25,89 @@ export default { else value.table = { ...value.table, category: "Tag settings" }; return { ...acc, [key]: value }; }, {}), - ariaLabel: { - name: "Aria-label", - type: { name: "string" }, + isInvalid: { + ...isInvalid, + description: "Displays help text below the tag group with invalid icon and styling.", + if: { arg: "helpText", neq: "" }, + }, + ariaLabel: { table: { disable: true } }, + label: { table: { disable: true } }, + items: { table: { disable: true } }, + actionButtonText: { + name: "Action button text", + description: "Displays an action button below the tag group, if left blank, the action button will not be displayed.", + type: { name: "text" }, + table: { + type: { summary: "text" }, + category: "Action button settings", + }, + control: "text", + }, + hasDisabledActionButton: { + name: "Has disabled action button", + description: "Displays the action button in a disabled state.", + type: { name: "boolean" }, table: { - type: { summary: "string" }, + type: { summary: "boolean" }, + category: "Action button settings", + }, + if: { arg: "actionButtonText", truthy: true }, + }, + fieldLabel: { + name: "Field label", + description: "Displays a label above the tag group, if left blank, the label will not be displayed.", + type: { name: "text" }, + table: { + type: { summary: "text" }, category: "Content", }, - control: { type: "text" }, + control: "text", }, - items: { table: { disable: true } }, - isRemovable: { - name: "Removable tags", - description: "True if a button is present to clear the tag.", + fieldLabelPosition: { + name: "Field label position", type: { name: "boolean" }, table: { type: { summary: "boolean" }, - category: "Shared settings", + category: "Content", + }, + options: ["top", "side"], + control: "select", + if: { arg: "fieldLabel", truthy: true }, + }, + helpText: { + name: "Help text", + description: "Displays help text below the tag group, if left blank, the help text will not be displayed.", + type: { name: "text" }, + table: { + type: { summary: "text" }, + category: "Content", }, - control: "boolean", + control: "text", + }, + numberOfTags: { + name: "Number of tags", + description: "The number of tags to display in the tag group. If the number of tags is 0, the tag group will show a placeholder text to communicate the empty state.", + type: { name: "number" }, + table: { + type: { summary: "number" }, + category: "Content", + }, + control: { type: "number", min: 0, max: 30, step: 1 }, }, }, args: { + ...TagStories.args, rootClass: "spectrum-TagGroup", isRemovable: false, size: "m", + actionButtonText: "Show all", + helpText: "Help text description", + fieldLabel: "Tag group label", + fieldLabelPosition: "top", + isInvalid: false, + numberOfTags: 3, + ariaLabel: "Tags", + hasDisabledActionButton: false, }, parameters: { actions: { @@ -61,66 +121,196 @@ export default { }, packageJson, metadata, - }, -}; - -export const Default = TagGroups.bind({}); -Default.args = { - ariaLabel: "Tags", - items: [ - { - label: "Tag 1", + downState: { + selectors: [".spectrum-Tag", ".spectrum-ActionButton"], }, - { - label: "Tag 2", - }, - { - label: "Tag 3", + status: { + type: "migrated", }, + }, + decorators: [ + withDownStateDimensionCapture, ], + tags: ["migrated"], }; +export const Default = TagGroups.bind({}); +Default.tags = ["!autodocs"]; + // ********* DOCS ONLY ********* // +/** + * A tag group on its own should always have a label. Labels can be placed either on top or on the side of the tags, but top labels are the default and are recommended because they work better with long copy, localization, and responsive layouts. + */ +export const DefaultWithLabel = TagGroups.bind({}); +DefaultWithLabel.storyName = "Label position - default/top"; +DefaultWithLabel.tags = ["!dev"]; +DefaultWithLabel.parameters = { + chromatic: { + disableSnapshot: true, + }, +}; +DefaultWithLabel.args = { + actionButtonText: "", + helpText: "", + items: exampleTagItems, + fieldLabel: "Tags", +}; + +/** + * Tag group labels can also be placed on the side of the tag group. Side labels are most useful when vertical space is limited. + */ +export const SideLabel = Template.bind({}); +SideLabel.storyName = "Label position - side"; +SideLabel.tags = ["!dev"]; +SideLabel.parameters = { + chromatic: { + disableSnapshot: true, + }, +}; +SideLabel.args = { + fieldLabelPosition: "side", + items: exampleTagItems, + fieldLabel: "Tags", + helpText: "These tags were automatically added." +}; + /** * A tag group can contain removable tags when the context is for editing or non-removable tags when tags are read-only. Removable and non-removable tags cannot be combined within the tag group. + * + * When horizontal space is limited in a tag group, the tags wrap to form another line. Individual tags don't wrap between lines; they'll either move to the next line or the text within the tag will truncate. */ -export const Removable = Template.bind({}); -Removable.tags = ["!dev"]; -Removable.parameters = { +export const RemovableAndWrapping = Template.bind({}); +RemovableAndWrapping.storyName = "Removable and wrapping"; +RemovableAndWrapping.tags = ["!dev"]; +RemovableAndWrapping.parameters = { chromatic: { disableSnapshot: true, }, }; -Removable.args = { +RemovableAndWrapping.args = { + fieldLabel: "Tags", + actionButtonText: "", + helpText: "", isRemovable: true, - isEmphasized: false, - customStyles: {"max-width": "300px"}, + customStyles: {"max-inline-size": "300px"}, items: [ { - label: "Tag 1 Example", + label: "Hiking and camping", }, { - label: "Tag 2 Example", + label: "Surfing", }, { - label: "Tag 3 Example", + label: "Outdoors", }, { - label: "Tag 4", + label: "Tag with avatar", avatarUrl: "example-ava.png", }, { - label: "Tag 5", + label: "Traveling", }, { - label: "Tag 6", + label: "Tag with thumbnail", + thumbnailUrl: "flowers.png", }, { - label: "Tag 7", + label: "Tag with icon", + iconName: "Cloud", }, ], }; +/** + * A single quiet [action button](?path=/docs/components-action-button--docs) may be included at the end of a tag group if the action affects the entire group. Common actions include "show all," "show less," and "clear all." A counter of the number of tags can be included in the action button label if appropriate for the context. + */ +export const WithActionButton = Template.bind({}); +WithActionButton.storyName = "With action button"; +WithActionButton.tags = ["!dev"]; +WithActionButton.parameters = { + chromatic: { + disableSnapshot: true, + }, +}; +WithActionButton.args = { + actionButtonText: "Show all (13)", + helpText: "", + items: exampleTagItems, + fieldLabel: "Tags", +}; + +/** + * A tag group can have [help text](?path=/docs/components-help-text--docs) below the group to give extra context or instruction. The help text may be invalid, indicating an error for when requirements aren't met. + */ +export const WithHelpText = Template.bind({}); +WithHelpText.storyName = "With help text"; +WithHelpText.tags = ["!dev"]; +WithHelpText.parameters = { + chromatic: { + disableSnapshot: true, + }, +}; +WithHelpText.args = { + fieldLabel: "Tags", + isInvalid: true, + actionButtonText: "", + helpText: "Add at least three tags.", + items: [ + { label: "2025" }, + { label: "Australia" }, + ], +}; + +/** + * Avoid disabling an entire tag group. In cases where users can't interact with an entire group of tags, consider either using non-removable tags or hiding the tag group altogether. Don't disable all individual tags; having a tag group that's disabled isn't accessible and it can be frustrating for users. + * + * Individual tags may be disabled, and the action button may also be disabled, as seen below. + */ +export const Disabled = TagGroupDisabledItemAndActionButton.bind({}); +Disabled.storyName = "With disabled tag and action button"; +Disabled.tags = ["!dev"]; +Disabled.parameters = { + chromatic: { + disableSnapshot: true, + }, +}; +Disabled.args = { + fieldLabel: "Tags", + helpText: "These tags were automatically added." +}; + +/** + * When a stand alone tag group has no tags, it may show placeholder text to communicate the empty state. The placeholder text can be customized, or another element may be shown to communicate the empty state rather than placeholder text. + */ +export const WithNoTags = Template.bind({}); +WithNoTags.storyName = "With no tags (empty state)"; +WithNoTags.tags = ["!dev"]; +WithNoTags.parameters = { + chromatic: { + disableSnapshot: true, + }, +}; +WithNoTags.args = { + fieldLabel: "Tags", + numberOfTags: 0, + helpText: "", + actionButtonText: "", +}; + +/** + * The default size of a tag group is medium, but tags are also available in small and large sizes. + */ +export const Sizing = (args, context) => Sizes({ + Template: TagGroupSizingTemplate, + withHeading: false, + withBorder: false, + ...args, +}, context); +Sizing.tags = ["!dev"]; +Sizing.parameters = { + chromatic: { disableSnapshot: true }, +}; + // ********* VRT ONLY ********* // export const WithForcedColors = TagGroups.bind({}); WithForcedColors.args = Default.args; diff --git a/components/taggroup/stories/taggroup.test.js b/components/taggroup/stories/taggroup.test.js index 737d8543f83..0c4078cf75f 100644 --- a/components/taggroup/stories/taggroup.test.js +++ b/components/taggroup/stories/taggroup.test.js @@ -1,45 +1,104 @@ import { Variants } from "@spectrum-css/preview/decorators"; +import { html } from "lit"; import { Template } from "./template.js"; +export const exampleTagItems = [ + { label: "2025" }, + { label: "Outdoors" }, + { label: "Blue" }, + { label: "Australia" }, + { label: "Project Alpha" }, + { label: "Project Beta" }, +]; + +const overflowingTagItems = [ + ...exampleTagItems, + { label: "Sports" }, + { label: "Surfing" }, + { label: "Water" }, + { label: "Hawaii" }, +]; + +const itemsWithDisabledTag = [ + ...exampleTagItems, + { label: "Disabled tag", isDisabled: true }, +]; + +export const TagGroupSizingTemplate = (args, context) => { + return html` + ${Template({ + ...args, + items: exampleTagItems, + customStyles: { + "max-width": "300px", + }, + }, context)} + `; +}; + +export const TagGroupDisabledItemAndActionButton = (args, context) => { + return html` + ${Template({ + ...args, + items: itemsWithDisabledTag, + hasDisabledActionButton: true, + }, context)} + `; +}; + export const TagGroups = Variants({ Template, + SizeTemplate: TagGroupSizingTemplate, sizeDirection: "row", testData: [ { testHeading: "Default", + actionButtonText: "", + helpText: "", + items: exampleTagItems, }, { - testHeading: "Is removable", + testHeading: "Removable, with action button and help text", isRemovable: true, + actionButtonText: "Show all", + helpText: "Add at least three tags.", + isInvalid: true, + items: [ + { label: "2025" }, + { label: "Australia" }, + ], }, { - testHeading: "Overflow", - isRemovable: true, - isEmphasized: false, + testHeading: "Top label variant showing action button, help text, and wrapping rows of tags", + actionButtonText: "Show all", + helpText: "Tags are automatically added.", + isInvalid: false, customStyles: {"max-width": "300px"}, - items: [ - { - label: "Tag 1 Example", - }, - { - label: "Tag 2 Example", - }, - { - label: "Tag 3 Example", - }, - { - label: "Tag 4", - }, - { - label: "Tag 5", - }, - { - label: "Tag 6", - }, - { - label: "Tag 7", - }, - ], - } + items: overflowingTagItems, + }, + { + testHeading: "Side label variant showing action button, help text, and wrapping rows of tags", + actionButtonText: "Show all", + helpText: "Tags are automatically added.", + isInvalid: false, + fieldLabelPosition: "side", + customStyles: {"max-width": "400px"}, + items: overflowingTagItems, + }, + { + testHeading: "Empty state, top label", + numberOfTags: 0, + helpText: "No tags added", + actionButtonText: "", + items: [], + }, + { + testHeading: "Empty state, side label", + fieldLabelPosition: "side", + numberOfTags: 0, + helpText: "No tags added", + actionButtonText: "", + items: [], + }, ], }); diff --git a/components/taggroup/stories/template.js b/components/taggroup/stories/template.js index 34bf5d50f77..0493d9da9da 100644 --- a/components/taggroup/stories/template.js +++ b/components/taggroup/stories/template.js @@ -1,38 +1,100 @@ +import { Template as ActionButton } from "@spectrum-css/actionbutton/stories/template.js"; +import { Template as FieldLabel } from "@spectrum-css/fieldlabel/stories/template.js"; +import { Template as HelpText } from "@spectrum-css/helptext/stories/template.js"; +import { getRandomId } from "@spectrum-css/preview/decorators"; import { Template as Tag } from "@spectrum-css/tag/stories/template.js"; +import { Template as Typography } from "@spectrum-css/typography/stories/template.js"; import { html } from "lit"; import { classMap } from "lit/directives/class-map.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { styleMap } from "lit/directives/style-map.js"; +import { when } from "lit/directives/when.js"; import "../index.css"; export const Template = ({ rootClass = "spectrum-TagGroup", ariaLabel, + id = getRandomId("taggroup"), + numberOfTags = 3, items = [], isRemovable = false, customClasses = [], customStyles = {}, size = "m", + actionButtonText = "", + fieldLabel, + fieldLabelPosition = "top", + helpText, + isInvalid = false, + hasDisabledActionButton = false, ...args } = {}, context = {}) => { + const tags = Array.isArray(items) && items.length > 0 + ? items + : (typeof numberOfTags === "number" && numberOfTags > 0 + ? Array.from({ length: numberOfTags }, (_, i) => ({ label: `Tag ${i + 1}` })) + : []); + return html` -