diff --git a/packages/craftcms-ui/scripts/build.js b/packages/craftcms-ui/scripts/build.js index 680cb1f3244..b21c760b1dd 100755 --- a/packages/craftcms-ui/scripts/build.js +++ b/packages/craftcms-ui/scripts/build.js @@ -106,7 +106,7 @@ async function generateVueWrappers() { async function generateColors() { spinner.start('Generating Colors'); - createColors(); + await createColors(); spinner.succeed(); return Promise.resolve(); } diff --git a/packages/craftcms-ui/scripts/generate-colors.js b/packages/craftcms-ui/scripts/generate-colors.js index 006f9f1dd8b..aa8df54f4cf 100644 --- a/packages/craftcms-ui/scripts/generate-colors.js +++ b/packages/craftcms-ui/scripts/generate-colors.js @@ -1,47 +1,27 @@ -import {writeFileSync} from 'fs'; +import {writeFileSync, readFileSync} from 'fs'; import {dirname, resolve} from 'path'; import {fileURLToPath} from 'url'; +import {transformSync} from 'esbuild'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); const OUT_FILE = resolve(ROOT, 'src/styles/shared/colorable.css'); +const DATA_FILE = resolve(ROOT, 'src/constants/colors.data.ts'); /* -This is the same as the colors in constants/colors but we can't import -typescript into this JS file so we had to duplicate for now. +The canonical color data lives in src/constants/colors.data.ts (shared with +constants/colors.ts). This script can't import TypeScript directly, so we +transpile it with esbuild and import the result. */ -const availableColors = [ - 'red', - 'orange', - 'amber', - 'yellow', - 'lime', - 'green', - 'emerald', - 'teal', - 'cyan', - 'sky', - 'blue', - 'indigo', - 'violet', - 'purple', - 'fuchsia', - 'pink', - 'rose', - 'white', - 'gray', - 'black', - 'slate', -]; - -const semanticColors = { - neutral: 'slate', - accent: 'red', - info: 'blue', - success: 'emerald', - warning: 'yellow', - danger: 'red', -}; +async function loadColorData() { + const {code} = transformSync(readFileSync(DATA_FILE, 'utf8'), { + loader: 'ts', + format: 'esm', + }); + return import( + `data:text/javascript;base64,${Buffer.from(code).toString('base64')}` + ); +} const colorIncrements = { fill: { @@ -103,8 +83,8 @@ function colorScale(color) { } } -function buildColorableTokens() { - return availableColors +function buildColorableTokens(paletteColors) { + return paletteColors .map((color) => { const s = colorScale(color); return [ @@ -123,7 +103,7 @@ function buildColorableTokens() { .join('\n\n'); } -function buildSemanticTokens() { +function buildSemanticTokens(semanticColors) { let declarations = []; for (const [meaning, color] of Object.entries(semanticColors)) { const s = colorScale(color); @@ -161,22 +141,29 @@ function buildStyleBlock(color) { }`; } -function generateStyles(colors) { +function generateStyles(paletteColors, semanticColors) { return `/* Auto-generated by scripts/generate-colors.js — do not edit manually */ :root { -${buildColorableTokens()} -${buildSemanticTokens()} +${buildColorableTokens(paletteColors)} +${buildSemanticTokens(semanticColors)} } -${[...availableColors, ...Object.keys(semanticColors)].map((c) => buildStyleBlock(c)).join('\n')} +${[...paletteColors, ...Object.keys(semanticColors)] + .map((c) => buildStyleBlock(c)) + .join('\n')} `; } -export default function main() { - const css = generateStyles(availableColors); +export default async function main() { + const {paletteColors, semanticColors} = await loadColorData(); + const css = generateStyles(paletteColors, semanticColors); writeFileSync(OUT_FILE, css); console.log(`Generated ${OUT_FILE}`); } -main(); +// Run when invoked directly (node scripts/generate-colors.js), but not when +// imported by build.js (which awaits the default export instead). +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/packages/craftcms-ui/src/components/action-menu/action-menu.stories.ts b/packages/craftcms-ui/src/components/action-menu/action-menu.stories.ts index 71cdb1cb091..cc659624a5f 100644 --- a/packages/craftcms-ui/src/components/action-menu/action-menu.stories.ts +++ b/packages/craftcms-ui/src/components/action-menu/action-menu.stories.ts @@ -124,7 +124,7 @@ export const DataDrivenDisabled: Story = { export const SlotBasedDisabled: Story = { render: () => html` - + Custom invoker
@@ -140,7 +140,7 @@ export const SlotBasedDisabled: Story = { export const DataDrivenWithCustomInvoker: Story = { render: () => html` - + Custom invoker `, @@ -156,7 +156,7 @@ export const DataDrivenWithCustomInvoker: Story = { export const Searchable: Story = { render: () => html` - + Add a field @@ -218,7 +218,7 @@ export const SearchableDataDriven: Story = { export const SearchableWithHiddenItems: Story = { render: () => html` - + Add a field diff --git a/packages/craftcms-ui/src/components/button-group/button-group.stories.ts b/packages/craftcms-ui/src/components/button-group/button-group.stories.ts index d67c788ee67..7c584576fe5 100644 --- a/packages/craftcms-ui/src/components/button-group/button-group.stories.ts +++ b/packages/craftcms-ui/src/components/button-group/button-group.stories.ts @@ -70,29 +70,23 @@ export const RadioMode: Story = { }; /** - * Radio mode works across all button appearances. The selected button retains - * its `active` styling regardless of the chosen appearance. + * Radio mode works across all button variants. The selected button retains + * its `active` styling regardless of the chosen variant. */ -export const RadioModeAppearances: Story = { - name: 'Radio mode — appearances', +export const RadioModeVariants: Story = { + name: 'Radio mode — variants', render: () => html`
- ${(['fill', 'plain'] as const).map( - (appearance) => html` + ${(['solid', 'fill', 'outline', 'dashed', 'plain'] as const).map( + (variant) => html`
${appearance}${variant} - - A - B - C + + A + B + C
` @@ -141,7 +135,7 @@ export const FormAssociated: Story = { Table Thumbnails - Submit + Submit `, }; diff --git a/packages/craftcms-ui/src/components/button/Docs.mdx b/packages/craftcms-ui/src/components/button/Docs.mdx index aba2d6ced23..72697740939 100644 --- a/packages/craftcms-ui/src/components/button/Docs.mdx +++ b/packages/craftcms-ui/src/components/button/Docs.mdx @@ -9,38 +9,42 @@ A button represents a clickable element that performs an action when activated. -## Appearance - -`appearance` controls how visually prominent the button is. Use it to signal importance relative to other buttons on the page. - -- `solid` — Solid colored background. The highest-emphasis style. Use for the single most important action in a given context. -- `outline` — Transparent background with a colored border. Medium emphasis. Use for secondary actions alongside a solid button. -- `plain` — No background or border. Lowest emphasis. Use for tertiary or destructive actions that shouldn't draw attention on their own. - ## Variant -`variant` controls the button's color, which communicates the _intent_ of the action — not how prominent it is. Appearance and variant are independent: any appearance can be combined with any variant. +`variant` is the single axis that sets a button's color and appearance together — communicating both the intent of the action and how prominent it is. -- `accent` — Blue. Use for the primary action on a page, such as Save or Confirm. -- `neutral` — Gray. The default. Use for everyday actions that don't carry a strong positive or negative meaning. -- `danger` — Red. Use for actions that delete, remove, or permanently change data. +- `primary` — Solid accent color. The highest-emphasis style; use for the single most important action in a context, such as Save or Confirm. +- `danger` — Solid red. Use for actions that delete, remove, or permanently change data. +- `solid` — Solid neutral (gray). A prominent action without a strong positive or negative meaning. +- `fill` — Soft neutral fill. The default; use for everyday actions. +- `outline` — Transparent background with a neutral border. Medium emphasis; use for secondary actions alongside a `primary` or `solid` button. +- `dashed` — Like `outline`, with a dashed border. +- `plain` — No background or border. Lowest emphasis; use for tertiary actions that shouldn't draw attention on their own. +- `link` — Renders as an inline text hyperlink. +- `none` — Completely unstyled; provides behavior only. - +`primary` and `danger` are colored and stay stable in any context; every other variant uses the neutral palette. -### Inherit +## Inherit -`variant="inherit"` is a special case for buttons placed inside [Colorable](?path=/docs/concepts-colorable--docs) containers. Instead of applying its own color, the button picks up the color from its parent container. +Add the boolean `inherit` property to a button placed inside a [Colorable](?path=/docs/concepts-colorable--docs) container (such as a `craft-callout` or any `[data-color]` element). Its neutral variants then adopt the container's color instead of the neutral palette, while still honoring the chosen appearance (`solid`, `fill`, `outline`, and so on). -Avoid using the standard color variants (`accent`, `neutral`, `danger`) inside Colorable components — the two color systems can conflict and produce unexpected results. +`primary` and `danger` ignore `inherit` and keep their own colors, so they remain stable inside colorable containers. + + ## Sizes -## Icon buttons +## Icons - + ## Loading state + +## Links + + diff --git a/packages/craftcms-ui/src/components/button/button.stories.ts b/packages/craftcms-ui/src/components/button/button.stories.ts index e102011313c..143db30510b 100644 --- a/packages/craftcms-ui/src/components/button/button.stories.ts +++ b/packages/craftcms-ui/src/components/button/button.stories.ts @@ -5,10 +5,9 @@ import {html} from 'lit'; import './button.js'; import '../icon/icon.js'; import '../chip/chip.js'; -import {ButtonVariant, ButtonAppearance} from '@src/components/button/button'; +import {ButtonVariant} from '@src/components/button/button'; const buttonVariants = Object.values(ButtonVariant); -const appearance = Object.values(ButtonAppearance); // More on how to set up stories at: https://storybook.js.org/docs/writing-stories const meta = { @@ -19,76 +18,79 @@ const meta = { }, args: { label: 'Button', - appearance: 'solid', loading: false, - variant: 'neutral', + variant: ButtonVariant.Fill, }, argTypes: { - appearance: { + variant: { control: {type: 'select'}, - options: appearance, + options: buttonVariants, }, loading: { control: {type: 'boolean'}, }, }, - render: (args) => html` -
+} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * Every variant. `primary` and `danger` are solid and colored; the rest are the + * neutral palette in their named appearance. + */ +export const Default: Story = { + render: () => html` +
${buttonVariants.map( (variant) => html` -
- ${variant ?? 'None'} solid - ${variant} outline${variant} + ` + )} +
+ `, +}; + +/** + * With the `inherit` property, the neutral variants adopt the ambient colorable + * palette (any `[data-color]` / colorable ancestor, e.g. a callout). `primary` + * and `danger` keep their own colors regardless. + */ +export const Inherit: Story = { + render: () => html` +
+ ${['accent', 'violet', 'success'].map( + (color) => html` +
+ ${[ + ButtonVariant.Solid, + ButtonVariant.Fill, + ButtonVariant.Outline, + ButtonVariant.Dashed, + ButtonVariant.Plain, + ButtonVariant.Link, + ].map( + (variant) => html` + ${variant} + ` + )} + primary - ${variant} plaindanger
` )} - - -
- Chip Buttons - Outline - Plain -
-
`, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args -export const Default: Story = { - args: {}, -}; - -export const Variants: Story = { - args: { - variant: 'accent', - }, - argTypes: { - variant: { - control: {type: 'select'}, - options: buttonVariants, - }, - }, - render: () => - html`${buttonVariants.map( - (variant) => html` - ${variant} - ` - )}`, }; export const Sizes: Story = { @@ -102,10 +104,18 @@ export const Sizes: Story = { `, }; -export const Icon: Story = { - args: {}, - render: (args) => html` -
+export const Icons: Story = { + render: () => html` +
+ Prefix icon + Suffix icon + + + Slotted prefix + + @@ -130,10 +140,10 @@ export const Links: Story = { render: () => html`
- ${appearance.map( - (a) => html` - ${a} link html` + ${variant} link ` )} diff --git a/packages/craftcms-ui/src/components/button/button.styles.ts b/packages/craftcms-ui/src/components/button/button.styles.ts index 29b00c0b00b..784cdb9cd39 100644 --- a/packages/craftcms-ui/src/components/button/button.styles.ts +++ b/packages/craftcms-ui/src/components/button/button.styles.ts @@ -1,4 +1,5 @@ import {css} from 'lit'; + export default css` :host { /* Necessary to use variables here to override the default active style specificity */ @@ -60,14 +61,60 @@ export default css` ); } - :host(:focus:not([disabled])), - :host(:focus-visible) { - outline: var(--c-focus-outline-width) solid var(--_focus-outline-color); - outline-offset: var(--c-focus-outline-offset); - } - - :host(:focus-visible) { - --_focus-outline-color: var(--c-color-focus-outline); + /* + Color palette per variant. + The single 'variant' axis picks both a palette and an appearance. Here we map + the generic --c-color-* props to a palette; 'primary' uses accent, 'danger' + uses danger, everything else uses neutral, and 'inherit' sets nothing so it + adopts the parent's theme. + */ + /* + Neutral variants use the neutral palette by default. With [inherit] we skip + this override so the ambient --c-color-* (set by a colorable ancestor) flows + through instead. primary/danger set their palettes unconditionally below, so + they are unaffected by [inherit]. + */ + :host([variant~='solid']:not([inherit])), + :host([variant~='fill']:not([inherit])), + :host([variant~='outline']:not([inherit])), + :host([variant~='dashed']:not([inherit])), + :host([variant~='plain']:not([inherit])), + :host([variant~='link']:not([inherit])), + :host([variant~='none']:not([inherit])) { + --c-color-fill-loud: var(--c-color-neutral-fill-loud); + --c-color-fill-normal: var(--c-color-neutral-fill-normal); + --c-color-fill-quiet: var(--c-color-neutral-fill-quiet); + --c-color-border-loud: var(--c-color-neutral-border-loud); + --c-color-border-normal: var(--c-color-neutral-border-normal); + --c-color-border-quiet: var(--c-color-neutral-border-quiet); + --c-color-on-loud: var(--c-color-neutral-on-loud); + --c-color-on-normal: var(--c-color-neutral-on-normal); + --c-color-on-quiet: var(--c-color-neutral-on-quiet); + } + + :host([variant~='primary']) { + --c-color-fill-loud: var(--c-color-accent-fill-loud); + --c-color-fill-normal: var(--c-color-accent-fill-normal); + --c-color-fill-quiet: var(--c-color-accent-fill-quiet); + --c-color-border-loud: var(--c-color-accent-border-loud); + --c-color-border-normal: var(--c-color-accent-border-normal); + --c-color-border-quiet: var(--c-color-accent-border-quiet); + --c-color-on-loud: var(--c-color-accent-on-loud); + --c-color-on-normal: var(--c-color-accent-on-normal); + --c-color-on-quiet: var(--c-color-accent-on-quiet); + } + + :host([variant~='danger-plain']), + :host([variant~='danger']) { + --c-color-fill-loud: var(--c-color-danger-fill-loud); + --c-color-fill-normal: var(--c-color-danger-fill-normal); + --c-color-fill-quiet: var(--c-color-danger-fill-quiet); + --c-color-border-loud: var(--c-color-danger-border-loud); + --c-color-border-normal: var(--c-color-danger-border-normal); + --c-color-border-quiet: var(--c-color-danger-border-quiet); + --c-color-on-loud: var(--c-color-danger-on-loud); + --c-color-on-normal: var(--c-color-danger-on-normal); + --c-color-on-quiet: var(--c-color-danger-on-quiet); } @media (hover: hover) { @@ -80,6 +127,16 @@ export default css` } } + :host(:focus:not([disabled])), + :host(:focus-visible) { + outline: var(--c-focus-outline-width) solid var(--_focus-outline-color); + outline-offset: var(--c-focus-outline-offset); + } + + :host(:focus-visible) { + --_focus-outline-color: var(--c-color-focus-outline); + } + :host(:not(:disabled):not(.loading):active), :host(.is-active:not(:disabled):not(.loading)) { color: var(--_active-color); @@ -87,10 +144,11 @@ export default css` box-shadow: inset 0 1px 3px var(--c-color-mix-active); } + /* Selected state (e.g. inside a radio button-group): show the loud fill. */ :host(:not(:disabled):not(.loading)[active]), :host(.is-active:not(:disabled):not(.loading)) { - color: var(--c-color-on-loud); background-color: var(--c-color-fill-loud); + color: var(--c-color-on-loud); border-color: var(--c-color-border-loud); } @@ -152,185 +210,181 @@ export default css` } /* - Appearances + Variants (appearance × palette) */ - /* Inline */ - :host([appearance~='inline']) { - display: inline; - appearance: none; - background-color: transparent; - border-color: currentColor; - color: inherit; - font: inherit; - padding: 0; - min-height: auto; - min-width: auto; - - &::before { - /* remove the sizer added by lion */ - display: none; - } - - .button-content { - padding: 0; - } + /* Solid — primary, danger, and solid are filled with the loud color. */ + :host([variant~='primary']), + :host([variant~='danger']), + :host([variant~='solid']) { + background-color: var( + --c-color-fill-loud, + var(--c-color-neutral-fill-loud) + ); + border-color: transparent; + color: var(--c-color-on-loud, var(--c-color-neutral-on-loud)); } - :host([appearance='inline']:not(:disabled):not(.loading):hover) { - background-color: color-mix( - in oklab, - var(--c-color-fill-quiet, var(--c-button-default-fill)), - var(--c-color-mix-hover) + :host([variant~='primary']:hover), + :host([variant~='danger']:hover), + :host([variant~='solid']:hover) { + background-color: hsl( + from var(--c-color-fill-loud, var(--c-color-neutral-fill-loud)) h s + calc(l - 5) ); - color: var(--c-color-on-quiet); + color: var(--c-color-on-loud, var(--c-color-neutral-on-loud)); } - :host([appearance='inline']:not(:disabled):not(.loading):active) { - color: var(--c-color-on-quiet, var(--c-color-neutral-on-quiet)); - background-color: color-mix( - in oklab, - var(--c-color-fill-quiet, var(--c-color-neutral-fill-quiet)), - var(--c-color-mix-active) + :host([variant~='primary']:active), + :host([variant~='danger']:active), + :host([variant~='solid']:active) { + --_active-background-color: hsl( + from var(--c-color-fill-loud, var(--c-color-neutral-fill-loud)) h s + calc(l - 10) ); + --_active-color: var(--c-color-on-loud, var(--c-color-neutral-on-loud)); } - :host([appearance='inline'][active]) { + /* Fill (default) — neutral normal fill. */ + :host([variant~='fill']) { + border-color: transparent; background-color: var( --c-color-fill-normal, var(--c-color-neutral-fill-normal) ); - border-color: var( - --c-color-border-normal, - var(--c-color-neutral-border-normal) - ); color: var(--c-color-on-normal, var(--c-color-neutral-on-normal)); - box-shadow: none; - } - - /* Plain & Outline (Shared) */ - :host([appearance~='plain']), - :host([appearance~='outline']) { - background-color: transparent; - color: var(--c-color-on-quiet); } - :host([appearance~='plain']:hover), - :host([appearance~='outline']:hover) { + :host([variant~='fill']:hover) { background-color: hsl( - from var(--c-color-fill-quiet, var(--c-color-neutral-fill-quiet)) h s + from var(--c-color-fill-normal, var(--c-color-neutral-fill-normal)) h s calc(l - 5) ); + color: var(--c-color-on-normal, var(--c-color-neutral-on-normal)); } - :host([appearance~='plain']:active), - :host([appearance~='outline']:active) { + :host([variant~='fill']:active) { --_active-background-color: hsl( - from var(--c-color-fill-quiet, var(--c-color-neutral-fill-quiet)) h s - calc(l - 8) + from var(--c-color-fill-normal, var(--c-color-neutral-fill-normal)) h s + calc(l - 10) ); - --_active-color: var(--c-color-on-quiet, var(--c-color-neutral-on-quiet)); - } - - :host([appearance~='plain'][active]), - :host([appearance~='outline'][active]) { - background-color: var(--c-color-fill-loud); - color: var(--c-color-on-loud); - border-color: var(--c-color-border-loud); + --_active-color: var(--c-color-on-normal, var(--c-color-neutral-on-normal)); } - /* Plain */ - :host([appearance~='plain']) { - border-color: transparent; - color: inherit; - - &:before { - display: none; - } + /* Outline, Dashed & Plain — transparent fill. */ + :host([variant~='outline']), + :host([variant~='dashed']), + :host([variant~='danger-plain']), + :host([variant~='plain']) { + background-color: transparent; + color: var(--c-color-on-quiet); } - :host([appearance='plain']:not(:disabled):not(.loading):hover) { + :host([variant~='outline']:not(:disabled):not(.loading):hover), + :host([variant~='dashed']:not(:disabled):not(.loading):hover), + :host([variant~='danger-plain']:not(:disabled):not(.loading):hover), + :host([variant~='plain']:not(:disabled):not(.loading):hover) { background-color: color-mix( in oklab, - var(--c-color-fill-quiet, var(--c-button-default-fill)), + var(--c-color-fill-quiet, var(--c-color-neutral-fill-quiet)), var(--c-color-mix-hover) ); color: var(--c-color-on-quiet); } - :host([appearance='plain']:not(:disabled):not(.loading):active) { - color: var(--c-color-on-quiet, var(--c-color-neutral-on-quiet)); - background-color: color-mix( + :host([variant~='outline']:not(:disabled):not(.loading):active), + :host([variant~='dashed']:not(:disabled):not(.loading):active), + :host([variant~='danger-plain']:not(:disabled):not(.loading):active), + :host([variant~='plain']:not(:disabled):not(.loading):active) { + --_active-background-color: color-mix( in oklab, var(--c-color-fill-quiet, var(--c-color-neutral-fill-quiet)), var(--c-color-mix-active) ); + --_active-color: var(--c-color-on-quiet, var(--c-color-neutral-on-quiet)); } - :host([appearance='plain'][active]) { - background-color: var(--c-color-fill-normal); - border-color: var(--c-color-border-normal); - color: var(--c-color-on-normal); - box-shadow: none; + /* Outline & Dashed — visible border. */ + :host([variant~='outline']), + :host([variant~='dashed']) { + border-color: var(--c-color-border-loud); } - /* Outline */ - :host([appearance='outline']) { - border-color: var(--c-color-border-loud); + :host([variant~='dashed']) { + --c-button-border-style: dashed; } - /* Solid */ - :host([appearance~='solid']) { - background-color: var( - --c-color-fill-loud, - var(--c-color-neutral-fill-loud) - ); + /* Plain — no border. */ + :host([variant~='danger-plain']), + :host([variant~='plain']) { border-color: transparent; - color: var(--c-color-on-loud, var(--c-color-neutral-on-loud)); + + &::before { + display: none; + } } - :host([appearance='solid']:hover) { - background-color: hsl( - from var(--c-color-fill-loud, var(--c-color-neutral-fill-loud)) h s - calc(l - 5) - ); - color: var(--c-color-on-loud, var(--c-color-neutral-on-loud)); + /* Link — renders as a text hyperlink: no fill or border, underlined. */ + :host([variant~='link']) { + display: inline-flex; + appearance: none; + background-color: transparent; + border-color: transparent; + color: var(--c-color-fill-loud, var(--c-color-neutral-fill-loud)); + font: inherit; + padding: 0; + min-height: auto; + min-width: auto; + text-decoration: underline; + + &::before { + /* remove the sizer added by lion */ + display: none; + } + + .button-content { + padding: 0; + } } - :host([appearance='solid']:active) { - --_active-background-color: hsl( + :host([variant~='link']:not(:disabled):not(.loading):hover) { + background-color: transparent; + color: hsl( from var(--c-color-fill-loud, var(--c-color-neutral-fill-loud)) h s calc(l - 10) ); - --_active-color: var(--c-color-on-loud, var(--c-color-neutral-on-loud)); + text-decoration: none; } - /* Fill */ - :host([appearance~='fill']) { - border-color: transparent; - background-color: var( - --c-color-fill-normal, - var(--c-color-neutral-fill-normal) - ); - border-color: transparent; - color: var(--c-color-on-normal, var(--c-color-neutral-on-normal)); + :host([variant~='link']:not(:disabled):not(.loading):active) { + background-color: transparent; } - :host([appearance='fill']:hover) { - background-color: hsl( - from var(--c-color-fill-normal, var(--c-color-neutral-fill-normal)) h s - calc(l - 5) - ); - color: var(--c-color-on-normal, var(--c-color-neutral-on-normal)); + /* None — completely unstyled; provides behavior only. */ + :host([variant~='none']) { + appearance: none; + background-color: transparent; + border-color: transparent; + border-width: 0; + color: inherit; + font: inherit; + padding: 0; + min-height: auto; + min-width: auto; + + &::before { + display: none; + } + + .button-content { + padding: 0; + } } - :host([appearance='fill'][active]), - :host([appearance='fill']:active) { - --_active-background-color: hsl( - from var(--c-color-fill-normal, var(--c-color-neutral-fill-normal)) h s - calc(l - 10) - ); - --_active-color: var(--c-color-on-normal, var(--c-color-neutral-on-normal)); + :host([variant~='none']:hover), + :host([variant~='none']:active) { + background-color: transparent; + color: inherit; } .button-content { @@ -356,16 +410,6 @@ export default css` justify-content: end; } - craft-button-group craft-button { - border-radius: 0; - } - - craft-button-reset, - craft-button-submit { - /* Temporarily make it very obvious when these are used */ - outline: 10px solid var(--c-button-danger-border); - } - .a11y-error { position: relative; outline: 2px solid var(--c-color-danger-border-normal) !important; diff --git a/packages/craftcms-ui/src/components/button/button.test.ts b/packages/craftcms-ui/src/components/button/button.test.ts index 83c66ed4e2f..f8d6c8962fc 100644 --- a/packages/craftcms-ui/src/components/button/button.test.ts +++ b/packages/craftcms-ui/src/components/button/button.test.ts @@ -91,6 +91,22 @@ describe('craft-button link semantics', () => { const element = await createButton(); expect(element.getAttribute('role')).toBe('button'); expect(element.tabIndex).toBe(0); + // Defaults to "button" even though we extend LionButtonSubmit. + expect(element.type).toBe('button'); + }); + + it('honors an explicit type="submit"', async () => { + const element = await createButton({type: 'submit'}); + expect(element.type).toBe('submit'); + }); + + it('restores an explicit type="submit" when href is removed', async () => { + const element = await createButton({type: 'submit', href: '/x'}); + expect(element.type).toBe('button'); + + element.href = null; + await element.updateComplete; + expect(element.type).toBe('submit'); }); diff --git a/packages/craftcms-ui/src/components/button/button.ts b/packages/craftcms-ui/src/components/button/button.ts index 856d022a2eb..3f914953539 100644 --- a/packages/craftcms-ui/src/components/button/button.ts +++ b/packages/craftcms-ui/src/components/button/button.ts @@ -6,24 +6,21 @@ import '../spinner/spinner.js'; import '../icon/icon.js'; import {computeAccessibleName} from 'dom-accessibility-api'; import {classMap} from 'lit/directives/class-map.js'; -import variantsStyles from '@src/styles/variants.styles'; -export const ButtonAppearance = { +export const ButtonVariant = { + Primary: 'primary', + Danger: 'danger', + DangerPlain: 'danger-plain', Solid: 'solid', Fill: 'fill', Outline: 'outline', + Dashed: 'dashed', Plain: 'plain', -} as const; - -export const ButtonVariant = { - Accent: 'accent', - Neutral: 'neutral', - Danger: 'danger', + Link: 'link', + None: 'none', } as const; export type ButtonVariant = (typeof ButtonVariant)[keyof typeof ButtonVariant]; -export type ButtonAppearance = - (typeof ButtonAppearance)[keyof typeof ButtonAppearance]; /** * @summary Interactive element that triggers an action or event. @@ -44,15 +41,29 @@ export type ButtonAppearance = */ export default class CraftButton extends LionButtonSubmit { static override get styles() { - return [...super.styles, variantsStyles, styles]; + return [...super.styles, styles]; + } + + constructor() { + super(); + // We extend LionButtonSubmit so `type="submit"`/`"reset"` Just Work inside + // forms, but a plain action button is the far more common case — and Lion + // gates all of its submit/reset helper wiring behind `type`, so defaulting + // to "button" also skips that runtime cost. (LionButtonSubmit's constructor + // sets this to "submit"; we override it after super().) + this.type = 'button'; } override connectedCallback() { // Set link-appropriate host state *before* Lion runs, so it skips its // role="button" assignment and (via type) its submit-helper wiring. if (this.href && !this.disabled) { + this.originalType = this.type; this.type = 'button'; this.setAttribute('role', 'presentation'); + // Mark link state as applied so syncLinkHostState() below re-applies the + // rest (tabindex) without re-capturing the (now-overwritten) type. + this.linkHostStateApplied = true; } super.connectedCallback(); this.syncLinkHostState(); @@ -67,13 +78,18 @@ export default class CraftButton extends LionButtonSubmit { private syncLinkHostState() { if (this.isLink) { + if (!this.linkHostStateApplied) { + // Capture the caller's intended type before we force "button" for the + // link, so we can restore it if `href` is later removed. + this.originalType = this.type; + } this.setAttribute('role', 'presentation'); this.tabIndex = -1; this.type = 'button'; this.linkHostStateApplied = true; } else if (this.linkHostStateApplied) { this.setAttribute('role', 'button'); - this.type = 'submit'; + this.type = this.originalType ?? 'button'; if (!this.disabled) { this.tabIndex = 0; } @@ -108,18 +124,17 @@ export default class CraftButton extends LionButtonSubmit { /** The computed accessible name */ @property({attribute: 'accessible-name'}) accessibleName: string; - /** Visual appearance of the button */ - @property({reflect: true}) appearance: ButtonAppearance = 'solid'; + /** + * The button's visual style. Defaults to "fill" (neutral fill). + */ + @property({reflect: true}) variant: ButtonVariant = ButtonVariant.Fill; /** - * Theme variant of the button. Defaults to "neutral" - * - * Accent: The primary action on a page - * Neutral: Used in most cases - * Danger: Indicates a dangerous action, when data will be removed or deleted - * Inherit: Useful for colorable elements, button will reflect the parent theme + * Adopt the ambient colorable palette (from a `[data-color]` / colorable + * ancestor) instead of the neutral palette. Only + * affects the neutral variants; `primary` and `danger` stay stable. */ - @property({reflect: true}) variant: ButtonVariant = 'neutral'; + @property({reflect: true, type: Boolean}) inherit: boolean = false; /** Size of the button. Defaults to "medium" */ @property({reflect: true}) size: 'zero' | 'small' | 'medium' | 'large' = @@ -161,6 +176,9 @@ export default class CraftButton extends LionButtonSubmit { private linkHostStateApplied = false; + /** The caller's `type` before link mode forced it to "button". */ + private originalType: string | null = null; + private get isLink(): boolean { return !!this.href && !this.disabled; } diff --git a/packages/craftcms-ui/src/components/chip/chip.stories.ts b/packages/craftcms-ui/src/components/chip/chip.stories.ts index 27fdd32c427..3a93695b529 100644 --- a/packages/craftcms-ui/src/components/chip/chip.stories.ts +++ b/packages/craftcms-ui/src/components/chip/chip.stories.ts @@ -29,7 +29,7 @@ export const PrefixAndSuffix: Story = { This is a chip - + @@ -42,7 +42,7 @@ export const Thumbnail: Story = { This is a chip - + @@ -63,7 +63,7 @@ export const SuffixOnly: Story = { args: {}, render: (args) => html` - + This is a chip @@ -81,11 +81,7 @@ export const Colors: Story = { ([name, value]) => html` ${name} - Button ` diff --git a/packages/craftcms-ui/src/components/disclosure/disclosure.ts b/packages/craftcms-ui/src/components/disclosure/disclosure.ts index 81fedec2cd7..2e2aa95af73 100644 --- a/packages/craftcms-ui/src/components/disclosure/disclosure.ts +++ b/packages/craftcms-ui/src/components/disclosure/disclosure.ts @@ -11,7 +11,7 @@ import '../button/button.js'; * `slot="invoker"` trigger and `slot="content"` collapsible content. Without a * slotted invoker, renders a default one from the `label` attribute: * - * ${label} + * ${label} * * **External-target mode** — wraps a bare `button[type="button"]` whose * `aria-controls` names an element elsewhere in the document; toggling flips diff --git a/packages/craftcms-ui/src/components/info-icon/info-icon.ts b/packages/craftcms-ui/src/components/info-icon/info-icon.ts index 55e2653024e..1619fc0e4d8 100644 --- a/packages/craftcms-ui/src/components/info-icon/info-icon.ts +++ b/packages/craftcms-ui/src/components/info-icon/info-icon.ts @@ -105,7 +105,7 @@ export default class CraftInfoIcon extends LitElement { type="button" icon size="zero" - appearance="plain" + variant="plain" id="${this.id}" > diff --git a/packages/craftcms-ui/src/components/input-password/input-password.ts b/packages/craftcms-ui/src/components/input-password/input-password.ts index 44db35cca88..5b509218278 100644 --- a/packages/craftcms-ui/src/components/input-password/input-password.ts +++ b/packages/craftcms-ui/src/components/input-password/input-password.ts @@ -47,7 +47,6 @@ export default class CraftInputPassword extends LionInput { size="small" variant="plain" @click="${this.reveal}" - appearance="plain" > ${this._visible diff --git a/packages/craftcms-ui/src/components/nav-item/nav-item.ts b/packages/craftcms-ui/src/components/nav-item/nav-item.ts index 12e1ca40955..b3b177b37cf 100644 --- a/packages/craftcms-ui/src/components/nav-item/nav-item.ts +++ b/packages/craftcms-ui/src/components/nav-item/nav-item.ts @@ -113,7 +113,7 @@ export default class CraftNavItem extends LitElement { return html` diff --git a/packages/craftcms-ui/src/constants/colors.data.ts b/packages/craftcms-ui/src/constants/colors.data.ts new file mode 100644 index 00000000000..63b88ab4e5d --- /dev/null +++ b/packages/craftcms-ui/src/constants/colors.data.ts @@ -0,0 +1,57 @@ +/** + * Canonical color data — the single source of truth for colors in the design + * system. + * + * Consumed by: + * - `constants/colors.ts` (runtime `Color` map + types), and + * - `scripts/generate-colors.js` (generates `styles/shared/colorable.css`), + * which transpiles this file with esbuild since it can't import TS directly. + * + * Keep it dependency-free so it stays trivially transpilable. + */ + +/** The raw color palette (each has a full `--color--*` scale). */ +export const paletteColors = [ + 'red', + 'orange', + 'amber', + 'yellow', + 'lime', + 'green', + 'emerald', + 'teal', + 'cyan', + 'sky', + 'blue', + 'indigo', + 'violet', + 'purple', + 'fuchsia', + 'pink', + 'rose', + 'white', + 'gray', + 'black', + 'slate', +] as const; + +/** Semantic meaning → palette color. */ +export const semanticColors = { + neutral: 'slate', + accent: 'blue', + info: 'sky', + success: 'emerald', + warning: 'yellow', + danger: 'red', +} as const; + +/** Status name → palette color. */ +export const statusColors = { + pending: 'orange', + off: 'red', + suspended: 'red', + expired: 'red', + disabled: 'gray', + inactive: 'gray', + on: 'emerald', +} as const; diff --git a/packages/craftcms-ui/src/constants/colors.ts b/packages/craftcms-ui/src/constants/colors.ts index 30ecdc51187..813a01c22e1 100644 --- a/packages/craftcms-ui/src/constants/colors.ts +++ b/packages/craftcms-ui/src/constants/colors.ts @@ -1,44 +1,42 @@ -export const Color = { - Red: 'red', - Orange: 'orange', - Amber: 'amber', - Yellow: 'yellow', - Lime: 'lime', - Green: 'green', - Emerald: 'emerald', - Teal: 'teal', - Cyan: 'cyan', - Sky: 'sky', - Blue: 'blue', - Indigo: 'indigo', - Violet: 'violet', - Purple: 'purple', - Fuchsia: 'fuchsia', - Pink: 'pink', - Rose: 'rose', - White: 'white', - Gray: 'gray', - Black: 'black', +import {paletteColors, semanticColors, statusColors} from './colors.data'; + +type PaletteColor = (typeof paletteColors)[number]; - // Semantic - Neutral: 'slate', - Accent: 'red', - Success: 'emerald', - Warning: 'orange', - Danger: 'red', - Info: 'blue', +/** + * Named color map (label → palette color), combining the raw palette, semantic + * meanings, and status names from the shared {@link ./colors.data} source. + */ +type NamedColors = { + readonly [K in PaletteColor as Capitalize]: K; +} & { + readonly [K in keyof typeof semanticColors as Capitalize]: (typeof semanticColors)[K]; +} & { + readonly [K in keyof typeof statusColors as Capitalize]: (typeof statusColors)[K]; +}; - // Status - Pending: 'orange', - Off: 'red', - Suspended: 'red', - Expired: 'red', - Disabled: 'gray', - Inactive: 'gray', - On: 'emerald', -} as const; +const capitalize = (value: string): string => + value.charAt(0).toUpperCase() + value.slice(1); + +export const Color = { + ...Object.fromEntries( + paletteColors.map((color) => [capitalize(color), color]) + ), + ...Object.fromEntries( + Object.entries(semanticColors).map(([name, color]) => [ + capitalize(name), + color, + ]) + ), + ...Object.fromEntries( + Object.entries(statusColors).map(([name, color]) => [ + capitalize(name), + color, + ]) + ), +} as NamedColors; -export const colors = Object.values(Color); +/** All valid color values (the palette). */ +export const colors: PaletteColor[] = [...paletteColors]; -export type ColorKey = keyof typeof Color; -export type ColorValue = (typeof Color)[keyof typeof Color]; +export type ColorKey = keyof NamedColors; +export type ColorValue = PaletteColor; diff --git a/packages/craftcms-ui/src/index.ts b/packages/craftcms-ui/src/index.ts index dd43dfd4354..e175abf655a 100644 --- a/packages/craftcms-ui/src/index.ts +++ b/packages/craftcms-ui/src/index.ts @@ -14,7 +14,6 @@ export {default as CraftBreadcrumbs} from './components/breadcrumbs/breadcrumbs. export {default as CraftButtonGroup} from './components/button-group/button-group.js'; export { default as CraftButton, - ButtonAppearance, ButtonVariant, } from './components/button/button.js'; export {default as CraftCallout} from './components/callout/callout.js'; diff --git a/packages/craftcms-ui/src/stories/tokens/variants.stories.ts b/packages/craftcms-ui/src/stories/tokens/variants.stories.ts index 17e67e0b0a0..909b6efd29c 100644 --- a/packages/craftcms-ui/src/stories/tokens/variants.stories.ts +++ b/packages/craftcms-ui/src/stories/tokens/variants.stories.ts @@ -8,10 +8,9 @@ import '../../components/indicator/indicator.js'; import {appearances} from '@src/constants/appearances'; import {variants} from '@src/constants/variants'; -import {ButtonVariant, ButtonAppearance} from '@src/components/button/button'; +import {ButtonVariant} from '@src/components/button/button'; const buttonVariants = Object.values(ButtonVariant); -const buttonAppearances = Object.values(ButtonAppearance); const meta: Meta = { title: 'Tokens/Variants & Appearances', @@ -64,42 +63,20 @@ export const CalloutMatrix: Story = { }; /** - * Buttons use their own variant subset (accent, neutral, danger) - * and appearance subset (solid, outline, plain). + * Buttons use a single `variant` axis (primary, danger, solid, fill, outline, + * dashed, plain, link, none — plus inherit). */ export const ButtonMatrix: Story = { name: 'Button Matrix', render: () => html`
- - - - - ${buttonAppearances.map((a) => html``)} - - - - ${buttonVariants.map( - (variant) => html` - - - ${buttonAppearances.map( - (appearance) => html` - - ` - )} - - ` - )} - -
variant${a}
${variant} - - ${variant} - -
+
+ ${buttonVariants.map( + (variant) => html` + ${variant} + ` + )} +
`, }; diff --git a/packages/craftcms-ui/src/styles/shared/colorable.css b/packages/craftcms-ui/src/styles/shared/colorable.css index f3a9073c8d0..c97c886df11 100644 --- a/packages/craftcms-ui/src/styles/shared/colorable.css +++ b/packages/craftcms-ui/src/styles/shared/colorable.css @@ -243,26 +243,26 @@ --c-color-neutral-on-loud: var(--color-slate-50); /* Semantics colors - accent */ - --c-color-accent-fill-quiet: var(--color-red-50); - --c-color-accent-fill-normal: var(--color-red-100); - --c-color-accent-fill-loud: var(--color-red-600); - --c-color-accent-border-quiet: var(--color-red-400); - --c-color-accent-border-normal: var(--color-red-600); - --c-color-accent-border-loud: var(--color-red-800); - --c-color-accent-on-quiet: var(--color-red-800); - --c-color-accent-on-normal: var(--color-red-950); - --c-color-accent-on-loud: var(--color-red-50); + --c-color-accent-fill-quiet: var(--color-blue-50); + --c-color-accent-fill-normal: var(--color-blue-100); + --c-color-accent-fill-loud: var(--color-blue-600); + --c-color-accent-border-quiet: var(--color-blue-400); + --c-color-accent-border-normal: var(--color-blue-600); + --c-color-accent-border-loud: var(--color-blue-800); + --c-color-accent-on-quiet: var(--color-blue-800); + --c-color-accent-on-normal: var(--color-blue-950); + --c-color-accent-on-loud: var(--color-blue-50); /* Semantics colors - info */ - --c-color-info-fill-quiet: var(--color-blue-50); - --c-color-info-fill-normal: var(--color-blue-100); - --c-color-info-fill-loud: var(--color-blue-600); - --c-color-info-border-quiet: var(--color-blue-400); - --c-color-info-border-normal: var(--color-blue-600); - --c-color-info-border-loud: var(--color-blue-800); - --c-color-info-on-quiet: var(--color-blue-800); - --c-color-info-on-normal: var(--color-blue-950); - --c-color-info-on-loud: var(--color-blue-50); + --c-color-info-fill-quiet: var(--color-sky-50); + --c-color-info-fill-normal: var(--color-sky-100); + --c-color-info-fill-loud: var(--color-sky-600); + --c-color-info-border-quiet: var(--color-sky-400); + --c-color-info-border-normal: var(--color-sky-600); + --c-color-info-border-loud: var(--color-sky-800); + --c-color-info-on-quiet: var(--color-sky-800); + --c-color-info-on-normal: var(--color-sky-950); + --c-color-info-on-loud: var(--color-sky-50); /* Semantics colors - success */ --c-color-success-fill-quiet: var(--color-emerald-50); diff --git a/packages/craftcms-ui/src/styles/variants.styles.ts b/packages/craftcms-ui/src/styles/variants.styles.ts index a98f2c0c208..443520cda8c 100644 --- a/packages/craftcms-ui/src/styles/variants.styles.ts +++ b/packages/craftcms-ui/src/styles/variants.styles.ts @@ -1,7 +1,19 @@ import {css} from 'lit'; export default css` - :host([variant~='default']) { + :host([variant~='accent']) { + --c-color-fill-loud: var(--c-color-accent-fill-loud); + --c-color-fill-normal: var(--c-color-accent-fill-normal); + --c-color-fill-quiet: var(--c-color-accent-fill-quiet); + --c-color-border-loud: var(--c-color-accent-border-loud); + --c-color-border-normal: var(--c-color-accent-border-normal); + --c-color-border-quiet: var(--c-color-accent-border-quiet); + --c-color-on-loud: var(--c-color-accent-on-loud); + --c-color-on-normal: var(--c-color-accent-on-normal); + --c-color-on-quiet: var(--c-color-accent-on-quiet); + } + + :host([variant='neutral']) { --c-color-fill-loud: var(--c-color-neutral-fill-loud); --c-color-fill-normal: var(--c-color-neutral-fill-normal); --c-color-fill-quiet: var(--c-color-neutral-fill-quiet); diff --git a/resources/images/craftcms.svg b/resources/images/craftcms.svg new file mode 100644 index 00000000000..ce6010dfff0 --- /dev/null +++ b/resources/images/craftcms.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/js/common/components/ActionMenu.vue b/resources/js/common/components/ActionMenu.vue index c45815150d8..188d141190f 100644 --- a/resources/js/common/components/ActionMenu.vue +++ b/resources/js/common/components/ActionMenu.vue @@ -1,6 +1,5 @@