diff --git a/change/@ni-nimble-components-0f9f3301-9f8e-4a37-9bd5-bcfd51aa944e.json b/change/@ni-nimble-components-0f9f3301-9f8e-4a37-9bd5-bcfd51aa944e.json new file mode 100644 index 0000000000..6bd74a8e63 --- /dev/null +++ b/change/@ni-nimble-components-0f9f3301-9f8e-4a37-9bd5-bcfd51aa944e.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "step and anchor-step", + "packageName": "@ni/nimble-components", + "email": "1588923+rajsite@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/nimble-components/docs/css-guidelines.md b/packages/nimble-components/docs/css-guidelines.md index 113cc0e586..b3c51f1613 100644 --- a/packages/nimble-components/docs/css-guidelines.md +++ b/packages/nimble-components/docs/css-guidelines.md @@ -108,9 +108,11 @@ Some takeaways from the example: ## Prefer cascade for state changes -If you find yourself in complex logic with lots of `:not()` selectors it's possible the code should be reorganized to leverage the CSS cascade for overriding states. +Avoid using [`:not()` selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:not) or [selector lists](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Selectors/Selector_structure#selector_list) (`#main, .content {}`) and instead rely on CSS cascade for overriding states. This will likely result in groups of properties being duplicated across multiple selectors and that is desired and expected. + +Each selector should represent a single, clear state of the control and define a minimal set of properties that override the base configuration of the control. Duplicated property values are avoided by using well-named design tokens or private css custom properties. -States should flow from plain control -> hover -> focus -> active -> error -> disabled (which overrides all the others). +States should flow from plain control -> hover -> focus -> active -> disabled (which overrides all the others). For example: @@ -119,8 +121,6 @@ For example: :host(:hover) {} :host(${focusVisible}) {} /* focusVisible is specific to FAST */ :host(:active) {} -:host(:invalid) {} -:host(.custom-state) {} :host([disabled]) {} /* disabled styles override all others in the cascade*/ ``` @@ -131,20 +131,26 @@ Useful reference: [When do the :hover, :focus, and :active pseudo-classes apply? New controls and existing controls undergoing major refactoring should migrate to [CSS Cascade Layers with `@layer`](https://developer.mozilla.org/en-US/docs/Web/CSS/@layer) for organizing styles that override states. Cascade Layers with `@layer` define the order of precedence when multiple cascade layers are present. This helps enforce that property overrides have higher specificity over base styles without needing to modify selectors to increase specificity. For consistency, control styles using CSS Cascade Layers should follow these practices: -- Define the cascade layers: `@layer base, hover, focusVisible, active, disabled, top` - - Avoid changing the order of layers, changing layer names, or creating additional layers. -- Layers in the stylesheet should follow the order by which they are defined above. -- Ensure that all styles are in a layer; no styles for the control should be outside of a layer. -- Styles in layers should continue to follow existing best organization practices, including grouping using document order. +- Define the following cascade layers list exactly: `@layer base, hover, focusVisible, active, disabled, top;` + - The cascade layers list should be the first style defined in a control's top-level `css`. + - Avoid changing the order of layers, changing layer names, or creating additional layers. +- Layers in the stylesheet should follow the order by which they are defined above. +- Ensure that all styles are in a layer; no styles for the control should be outside of a layer. + - This includes leveraging shared styles that are imported. Note: imported styles must be shared as `cssPartial` instead of as a top-level `css` to be used in a layer. + - Custom states, like `appearance`, `checked`, etc. should be configured within one of the existing layers (likely in `base` with interaction overrides in different layers). +- Styles in layers should continue to follow existing best organization practices, including grouping using document order. For example: ```css -@layer base, hover, focusVisible, active, disabled, top +@layer base, hover, focusVisible, active, disabled, top; @layer base { :host { border: green; } + :host([custom-state]) { + border: violet; + } } @layer hover {} @layer focusVisible {} @@ -153,6 +159,9 @@ For example: :host([disabled]) { border: gray; } + :host([disabled][custom-state]) { + border: darkviolet; + } } @layer top {} ``` diff --git a/packages/nimble-components/src/anchor-step/index.ts b/packages/nimble-components/src/anchor-step/index.ts index d7319415dc..b411caa756 100644 --- a/packages/nimble-components/src/anchor-step/index.ts +++ b/packages/nimble-components/src/anchor-step/index.ts @@ -1,6 +1,12 @@ -import { DesignSystem, FoundationElement } from '@ni/fast-foundation'; +import { DesignSystem, type AnchorOptions } from '@ni/fast-foundation'; +import { attr, nullableNumberConverter } from '@ni/fast-element'; import { styles } from './styles'; import { template } from './template'; +import { AnchorBase } from '../anchor-base'; +import { mixinSeverityPattern } from '../patterns/severity/types'; +import type { StepPattern } from '../patterns/step/types'; +import { StepInternals } from '../patterns/step/models/step-internals'; +import { AnchorStepSeverity } from './types'; declare global { interface HTMLElementTagNameMap { @@ -11,12 +17,60 @@ declare global { /** * A nimble-styled anchor step for a stepper */ -export class AnchorStep extends FoundationElement {} +export class AnchorStep extends mixinSeverityPattern(AnchorBase) implements StepPattern { + /** + * @public + * @remarks + * HTML Attribute: disabled + */ + @attr() + public severity: AnchorStepSeverity = AnchorStepSeverity.default; -const nimbleAnchorStep = AnchorStep.compose({ + /** + * @public + * @remarks + * HTML Attribute: disabled + */ + @attr({ mode: 'boolean' }) + public disabled = false; + + /** + * @public + * @remarks + * HTML Attribute: readonly + */ + @attr({ attribute: 'readonly', mode: 'boolean' }) + public readOnly = false; + + /** + * @public + * @remarks + * HTML Attribute: selected + */ + @attr({ mode: 'boolean' }) + public selected = false; + + /** + * @public + * @remarks + * HTML Attribute: tabindex + */ + @attr({ attribute: 'tabindex', converter: nullableNumberConverter }) + public override tabIndex!: number; + + /** + * @internal + */ + public readonly stepInternals = new StepInternals(); +} + +const nimbleAnchorStep = AnchorStep.compose({ baseName: 'anchor-step', template, - styles + styles, + shadowOptions: { + delegatesFocus: true + } }); DesignSystem.getOrCreate().withPrefix('nimble').register(nimbleAnchorStep()); diff --git a/packages/nimble-components/src/anchor-step/styles.ts b/packages/nimble-components/src/anchor-step/styles.ts index 5d190a3e55..3f34605fec 100644 --- a/packages/nimble-components/src/anchor-step/styles.ts +++ b/packages/nimble-components/src/anchor-step/styles.ts @@ -1,6 +1,12 @@ import { css } from '@ni/fast-element'; -import { display } from '../utilities/style/display'; +import { styles as stepStyles } from '../patterns/step/styles'; export const styles = css` - ${display('flex')} + ${stepStyles} + ${'' /* Anchor specific styles */} + @layer base { + .control { + text-decoration: none; + } + } `; diff --git a/packages/nimble-components/src/anchor-step/template.ts b/packages/nimble-components/src/anchor-step/template.ts index 55a4994405..3ef1b8c899 100644 --- a/packages/nimble-components/src/anchor-step/template.ts +++ b/packages/nimble-components/src/anchor-step/template.ts @@ -1,6 +1,68 @@ -import { html } from '@ni/fast-element'; +import { html, ref, slotted, ViewTemplate } from '@ni/fast-element'; +import { type FoundationElementTemplate, type AnchorOptions, startSlotTemplate, endSlotTemplate } from '@ni/fast-foundation'; import type { AnchorStep } from '.'; +import { severityTextTemplate } from '../patterns/severity/template'; -export const template = html` - +export const template: FoundationElementTemplate< +ViewTemplate, +AnchorOptions +> = (context, definition) => html` + `; diff --git a/packages/nimble-components/src/anchor-step/types.ts b/packages/nimble-components/src/anchor-step/types.ts new file mode 100644 index 0000000000..de7f8749b4 --- /dev/null +++ b/packages/nimble-components/src/anchor-step/types.ts @@ -0,0 +1,4 @@ +import { Severity } from '../patterns/severity/types'; + +export const AnchorStepSeverity = Severity; +export type AnchorStepSeverity = (typeof AnchorStepSeverity)[keyof typeof AnchorStepSeverity]; diff --git a/packages/nimble-components/src/anchor/styles.ts b/packages/nimble-components/src/anchor/styles.ts index d70047a87d..3f27e370cb 100644 --- a/packages/nimble-components/src/anchor/styles.ts +++ b/packages/nimble-components/src/anchor/styles.ts @@ -12,7 +12,7 @@ import { } from '../theme-provider/design-tokens'; export const styles = css` - @layer base, hover, focusVisible, active, disabled; + @layer base, hover, focusVisible, active, disabled, top; @layer base { ${display('inline')} diff --git a/packages/nimble-components/src/breadcrumb-item/styles.ts b/packages/nimble-components/src/breadcrumb-item/styles.ts index 62535b6c2b..ded9aef4e8 100644 --- a/packages/nimble-components/src/breadcrumb-item/styles.ts +++ b/packages/nimble-components/src/breadcrumb-item/styles.ts @@ -14,7 +14,7 @@ import { import { focusVisible } from '../utilities/style/focus'; export const styles = css` - @layer base, hover, focusVisible, active, disabled; + @layer base, hover, focusVisible, active, disabled, top; @layer base { ${display('inline-flex')} diff --git a/packages/nimble-components/src/patterns/severity/styles.ts b/packages/nimble-components/src/patterns/severity/styles.ts new file mode 100644 index 0000000000..771d310b60 --- /dev/null +++ b/packages/nimble-components/src/patterns/severity/styles.ts @@ -0,0 +1,42 @@ +import { cssPartial } from '@ni/fast-element'; +import { + failColor, + errorTextFontLineHeight, + warningColor, + errorTextFont, + buttonLabelFontColor +} from '../../theme-provider/design-tokens'; + +// These styles end up inside a @layer block so must use the +// cssPartial tag instead of the css tag +export const styles = cssPartial` + .severity-text { + display: none; + } + + .severity-text { + display: block; + font: ${errorTextFont}; + color: ${buttonLabelFontColor}; + width: 100%; + position: absolute; + ${'' /* The -2px modifier of the bottom position is to intentionally have the severity text slightly overlap the control by 2px */} + bottom: calc(-1 * (${errorTextFontLineHeight} - 2px)); + left: 0px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + :host([severity="error"]) .severity-text { + color: ${failColor}; + } + + :host([severity="warning"]) .severity-text { + color: ${warningColor}; + } + + :host([severity]) .severity-text:empty { + display: none; + } +`; diff --git a/packages/nimble-components/src/patterns/severity/template.ts b/packages/nimble-components/src/patterns/severity/template.ts new file mode 100644 index 0000000000..38553ed07d --- /dev/null +++ b/packages/nimble-components/src/patterns/severity/template.ts @@ -0,0 +1,14 @@ +import { html } from '@ni/fast-element'; +import type { SeverityPattern } from './types'; +import { overflow } from '../../utilities/directive/overflow'; + +export const severityTextTemplate = html` +
+ ${x => x.severityText} +
+`; diff --git a/packages/nimble-components/src/patterns/severity/testing/severity-pattern.pageobject.ts b/packages/nimble-components/src/patterns/severity/testing/severity-pattern.pageobject.ts new file mode 100644 index 0000000000..7c6f539807 --- /dev/null +++ b/packages/nimble-components/src/patterns/severity/testing/severity-pattern.pageobject.ts @@ -0,0 +1,41 @@ +import type { FoundationElement } from '@ni/fast-foundation'; +import { processUpdates } from '../../../testing/async-helpers'; + +/** + * A page object for the elements that use the error pattern mixin. + */ +export class SeverityPatternPageObject { + public constructor(private readonly element: FoundationElement) {} + + public getDisplayedSeverityText(): string { + const severityTextDiv = this.getSeverityTextElement(); + if ( + !severityTextDiv + || getComputedStyle(severityTextDiv).display === 'none' + ) { + return ''; + } + return severityTextDiv.textContent?.trim() ?? ''; + } + + public getSeverityTextTitle(): string { + const severityTextDiv = this.getSeverityTextElement(); + if (!severityTextDiv) { + throw new Error('Severity text element not found'); + } + return severityTextDiv.title; + } + + public dispatchEventToSeverityText(event: MouseEvent): void { + const severityTextDiv = this.getSeverityTextElement(); + if (!severityTextDiv) { + throw new Error('Severity text element not found'); + } + severityTextDiv.dispatchEvent(event); + processUpdates(); + } + + private getSeverityTextElement(): HTMLDivElement | null { + return this.element.shadowRoot!.querySelector('.severity-text'); + } +} diff --git a/packages/nimble-components/src/patterns/severity/tests/severity-pattern.spec.ts b/packages/nimble-components/src/patterns/severity/tests/severity-pattern.spec.ts new file mode 100644 index 0000000000..23bf321d82 --- /dev/null +++ b/packages/nimble-components/src/patterns/severity/tests/severity-pattern.spec.ts @@ -0,0 +1,103 @@ +import { attr, css, customElement } from '@ni/fast-element'; +import { FoundationElement } from '@ni/fast-foundation'; +import { + type Fixture, + fixture, + uniqueElementName +} from '../../../utilities/tests/fixture'; +import { mixinSeverityPattern, Severity } from '../types'; +import { styles } from '../styles'; +import { severityTextTemplate } from '../template'; +import { processUpdates } from '../../../testing/async-helpers'; +import { SeverityPatternPageObject } from '../testing/severity-pattern.pageobject'; + +const elementName = uniqueElementName(); +@customElement({ + name: elementName, + template: severityTextTemplate, + styles: css`${styles}` +}) +class TestSeverityPattern extends mixinSeverityPattern(FoundationElement) { + @attr + public override severity: Severity; +} + +async function setup(): Promise> { + return await fixture(elementName); +} + +describe('SeverityPatternMixin', () => { + let element: TestSeverityPattern; + let connect: () => Promise; + let disconnect: () => Promise; + let pageObject: SeverityPatternPageObject; + + function setSeverity(severityText: string | undefined, severity: Severity): void { + element.severityText = severityText; + element.severity = severity; + processUpdates(); + } + + beforeEach(async () => { + ({ element, connect, disconnect } = await setup()); + await connect(); + pageObject = new SeverityPatternPageObject(element); + }); + + afterEach(async () => { + await disconnect(); + }); + + it('defaults severity to Severity.default to false', () => { + expect(element.severity).toBe(Severity.default); + }); + + it('defaults severityText to undefined', () => { + expect(element.severityText).toBeUndefined(); + }); + + it('shows severity text when severity error', () => { + const severityText = 'Something is wrong!'; + setSeverity(severityText, Severity.error); + expect(pageObject.getDisplayedSeverityText()).toBe(severityText); + }); + + it('does show severity text when severity default', () => { + const severityText = 'Something is wrong!'; + setSeverity(severityText, Severity.default); + expect(pageObject.getDisplayedSeverityText()).toBe(severityText); + }); + + describe('overflow behavior', () => { + beforeEach(() => { + element.style.display = 'block'; + element.style.width = '100px'; + }); + + it('sets title when error text is ellipsized', () => { + const severityText = 'a very long error message because something terrible happened and we need to show the user what it is'; + setSeverity(severityText, Severity.error); + + pageObject.dispatchEventToSeverityText(new MouseEvent('mouseover')); + expect(pageObject.getSeverityTextTitle()).toBe(severityText); + }); + + it('does not set title when error text is fully visible', () => { + setSeverity('abc', Severity.error); + + pageObject.dispatchEventToSeverityText(new MouseEvent('mouseover')); + expect(pageObject.getSeverityTextTitle()).toBe(''); + }); + + it('removes title on mouseout of error text', () => { + const errorText = 'a very long error message because something terrible happened and we need to show the user what it is'; + setSeverity(errorText, Severity.error); + + pageObject.dispatchEventToSeverityText(new MouseEvent('mouseover')); + expect(pageObject.getSeverityTextTitle()).toBe(errorText); + + pageObject.dispatchEventToSeverityText(new MouseEvent('mouseout')); + expect(pageObject.getSeverityTextTitle()).toBe(''); + }); + }); +}); diff --git a/packages/nimble-components/src/patterns/severity/types.ts b/packages/nimble-components/src/patterns/severity/types.ts new file mode 100644 index 0000000000..3d67e689ca --- /dev/null +++ b/packages/nimble-components/src/patterns/severity/types.ts @@ -0,0 +1,51 @@ +import { attr, FASTElement, observable } from '@ni/fast-element'; + +export const Severity = { + default: undefined, + error: 'error', + warning: 'warning', + success: 'success', +} as const; +export type Severity = (typeof Severity)[keyof typeof Severity]; + +export interface SeverityPattern { + severityText?: string; + severity: Severity; + severityHasOverflow: boolean; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type FASTElementConstructor = abstract new (...args: any[]) => FASTElement; + +// As the returned class is internal to the function, we can't write a signature that uses is directly, so rely on inference +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type +export function mixinSeverityPattern( + base: TBase +) { + /** + * Mixin to provide severity text related properties to an element + */ + abstract class SeverityPatternElement extends base implements SeverityPattern { + /** + * The severity state of the element + */ + public abstract severity: Severity; + + /** + * The severity text that will be displayed when a component is not in the default severity state + */ + public severityText?: string; + + /* @internal + * Indicates if the severity text has overflowed its container. The value should not be + * set directly. Instead, it is used with the `overflow` directive. + */ + public severityHasOverflow = false; + } + attr({ attribute: 'severity-text' })( + SeverityPatternElement.prototype, + 'severityText' + ); + observable(SeverityPatternElement.prototype, 'severityHasOverflow'); + return SeverityPatternElement; +} diff --git a/packages/nimble-components/src/patterns/step/models/step-internals.ts b/packages/nimble-components/src/patterns/step/models/step-internals.ts new file mode 100644 index 0000000000..0dc5e0e61a --- /dev/null +++ b/packages/nimble-components/src/patterns/step/models/step-internals.ts @@ -0,0 +1,13 @@ +import { observable } from '@ni/fast-element'; +import type { StepperOrientation } from '../../../stepper/types'; + +/** + * Internal properties configurable for a step + */ +export class StepInternals { + @observable + public orientation: StepperOrientation; + + @observable + public last = false; +} diff --git a/packages/nimble-components/src/patterns/step/styles.ts b/packages/nimble-components/src/patterns/step/styles.ts new file mode 100644 index 0000000000..009aa6a739 --- /dev/null +++ b/packages/nimble-components/src/patterns/step/styles.ts @@ -0,0 +1,369 @@ +import { css } from '@ni/fast-element'; +import { display } from '../../utilities/style/display'; +import { + buttonLabelFont, + buttonLabelFontColor, + smallPadding, + bodyFont, + errorTextFont, + controlSlimHeight, + borderRgbPartialColor, + borderHoverColor, + borderWidth, + smallDelay, + fillSelectedColor, + passColor, + failColor, + warningColor, + buttonLabelDisabledFontColor, + iconColor +} from '../../theme-provider/design-tokens'; +import { styles as severityStyles } from '../severity/styles'; +import { focusVisible } from '../../utilities/style/focus'; +import { userSelectNone } from '../../utilities/style/user-select'; + +export const styles = css` + @layer base, hover, focusVisible, active, disabled, top; + + @layer base { + ${display('inline-flex')} + ${severityStyles} + :host { + height: 46px; + color: ${buttonLabelFontColor}; + font: ${buttonLabelFont}; + white-space: nowrap; + outline: none; + border: none; + } + + ${'' /* Container wrapper for severity text to position against */} + .container { + display: inline-flex; + width: 100%; + height: 100%; + position: relative; + } + + .control { + display: inline-flex; + align-items: start; + justify-content: flex-start; + height: 100%; + width: 100%; + color: inherit; + background-color: transparent; + gap: ${smallPadding}; + cursor: pointer; + outline: none; + margin: 0; + padding: 0 ${smallPadding} 0 0; + --ni-private-step-icon-background-full-size: 100% 100%; + ${'' /* 6px = (2px icon border + 1px inset) * 2 sides */} + --ni-private-step-icon-background-inset-size: calc(100% - 6px) calc(100% - 6px); + --ni-private-step-icon-background-none-size: 0% 0%; + + --ni-private-step-icon-color: ${buttonLabelFontColor}; + --ni-private-step-icon-border-color: transparent; + --ni-private-step-icon-background-color: rgba(${borderRgbPartialColor}, 0.1); + --ni-private-step-icon-background-size: var(--ni-private-step-icon-background-full-size); + --ni-private-step-icon-outline-inset-color: transparent; + --ni-private-step-line-color: rgba(${borderRgbPartialColor}, 0.1); + } + + :host([severity="error"]) .control { + --ni-private-step-icon-border-color: ${failColor}; + --ni-private-step-icon-background-color: rgb(from ${failColor} r g b / 30%); + --ni-private-step-icon-background-size: var(--ni-private-step-icon-background-none-size); + --ni-private-step-line-color: ${failColor}; + } + + :host([severity="warning"]) .control { + --ni-private-step-icon-border-color: ${warningColor}; + --ni-private-step-icon-background-color: rgb(from ${warningColor} r g b / 30%); + --ni-private-step-icon-background-size: var(--ni-private-step-icon-background-none-size); + --ni-private-step-line-color: ${warningColor}; + } + + :host([severity="success"]) .control { + --ni-private-step-icon-border-color: ${passColor}; + --ni-private-step-icon-background-color: ${passColor}; + --ni-private-step-icon-background-size: var(--ni-private-step-icon-background-full-size); + --ni-private-step-line-color: rgba(${borderRgbPartialColor}, 0.1); + } + + :host([selected]) .control { + --ni-private-step-icon-color: ${borderHoverColor}; + --ni-private-step-icon-border-color: ${borderHoverColor}; + --ni-private-step-icon-background-color: rgb(from ${borderHoverColor} r g b / 30%); + --ni-private-step-icon-background-size: var(--ni-private-step-icon-background-none-size); + --ni-private-step-line-color: ${borderHoverColor}; + } + + .icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + height: 32px; + width: 32px; + ${userSelectNone}; + font: ${buttonLabelFont}; + color: var(--ni-private-step-icon-color); + ${iconColor.cssCustomProperty}: var(--ni-private-step-icon-color); + border-style: solid; + border-radius: 100%; + border-color: var(--ni-private-step-icon-border-color); + border-width: 1px; + background-image: radial-gradient( + closest-side, + ${'' /* Workaround to prevent aliasing on radial-gradient boundaries, see: + https://frontendmasters.com/blog/obsessing-over-smooth-radial-gradient-disc-edges + */} + var(--ni-private-step-icon-background-color) calc(100% - 1px/var(--ni-private-device-resolution)), + transparent 100% + ); + background-origin: border-box; + background-size: var(--ni-private-step-icon-background-size); + background-repeat: no-repeat; + background-position: center center; + position: relative; + transition: + border-color ${smallDelay} ease-in-out, + border-width ${smallDelay} ease-in-out, + background-size ${smallDelay} ease-out; + } + + :host([selected]) .icon { + border-width: 2px; + } + + .icon::before { + content: ''; + position: absolute; + width: 100%; + height: 100%; + pointer-events: none; + outline-color: var(--ni-private-step-icon-outline-inset-color); + outline-style: solid; + outline-width: 0px; + outline-offset: 0px; + border: 1px solid transparent; + border-radius: 100%; + color: transparent; + background-clip: border-box; + transition: + outline-color ${smallDelay} ease-in-out, + outline-width ${smallDelay} ease-in-out, + outline-offset ${smallDelay} ease-in-out; + } + + .content { + display: inline-flex; + width: 100%; + flex-direction: column; + padding-top: ${smallPadding}; + } + + .title-wrapper { + display: inline-flex; + height: ${controlSlimHeight}; + flex-direction: row; + align-items: center; + justify-content: start; + gap: ${smallPadding}; + font: ${bodyFont}; + } + + [part='start'] { + display: none; + } + + .title { + display: inline-block; + flex: none; + } + + [part='end'] { + display: none; + } + + .line { + display: inline-block; + flex: 1; + height: 1px; + background: var(--ni-private-step-line-color); + transform: scale(1, 1); + transition: + background-color ${smallDelay} ease-in-out, + transform ${smallDelay} ease-in-out; + } + + .subtitle { + font: ${errorTextFont}; + } + } + + @layer hover { + .control:hover { + --ni-private-step-icon-border-color: ${borderHoverColor}; + --ni-private-step-icon-background-size: var(--ni-private-step-icon-background-inset-size); + --ni-private-step-line-color: ${borderHoverColor}; + } + + :host([severity="error"]) .control:hover { + --ni-private-step-icon-border-color: ${failColor}; + --ni-private-step-icon-background-size: var(--ni-private-step-icon-background-none-size); + --ni-private-step-line-color: ${failColor}; + } + + :host([severity="warning"]) .control:hover { + --ni-private-step-icon-border-color: ${warningColor}; + --ni-private-step-icon-background-size: var(--ni-private-step-icon-background-none-size); + --ni-private-step-line-color: ${warningColor}; + } + + :host([severity="success"]) .control:hover { + --ni-private-step-icon-border-color: ${passColor}; + --ni-private-step-icon-background-size: var(--ni-private-step-icon-background-inset-size); + --ni-private-step-line-color: ${passColor}; + } + + :host([selected]) .control:hover { + --ni-private-step-icon-color: ${buttonLabelFontColor}; + --ni-private-step-icon-border-color: ${borderHoverColor}; + --ni-private-step-icon-background-size: var(--ni-private-step-icon-background-inset-size); + --ni-private-step-line-color: ${borderHoverColor}; + } + + .control:hover .icon { + border-width: 2px; + } + + .control:hover .line { + transform: scale(1, 2); + } + } + + @layer focusVisible { + .control${focusVisible} { + --ni-private-step-icon-border-color: ${borderHoverColor}; + --ni-private-step-icon-outline-inset-color: ${borderHoverColor}; + --ni-private-step-icon-background-size: var(--ni-private-step-icon-background-inset-size); + --ni-private-step-line-color: ${borderHoverColor}; + } + + :host([severity="error"]) .control${focusVisible} { + --ni-private-step-icon-border-color: ${failColor}; + --ni-private-step-icon-background-size: var(--ni-private-step-icon-background-none-size); + --ni-private-step-icon-outline-inset-color: ${failColor}; + --ni-private-step-line-color: ${failColor}; + } + + :host([severity="warning"]) .control${focusVisible} { + --ni-private-step-icon-border-color: ${warningColor}; + --ni-private-step-icon-background-size: var(--ni-private-step-icon-background-none-size); + --ni-private-step-icon-outline-inset-color: ${warningColor}; + --ni-private-step-line-color: ${warningColor}; + } + + :host([severity="success"]) .control${focusVisible} { + --ni-private-step-icon-border-color: ${passColor}; + --ni-private-step-icon-background-size: var(--ni-private-step-icon-background-inset-size); + --ni-private-step-icon-outline-inset-color: transparent; + --ni-private-step-line-color: ${passColor}; + } + + :host([selected]) .control${focusVisible} { + --ni-private-step-icon-color: ${borderHoverColor}; + --ni-private-step-icon-border-color: ${borderHoverColor}; + --ni-private-step-icon-background-size: var(--ni-private-step-icon-background-none-size); + --ni-private-step-icon-outline-inset-color: ${borderHoverColor}; + --ni-private-step-line-color: ${borderHoverColor}; + } + + .control${focusVisible} .icon { + border-width: 2px; + } + + .control${focusVisible} .icon::before { + outline-width: ${borderWidth}; + outline-offset: -2px; + } + + .control${focusVisible} .line { + transform: scale(1, 2); + } + } + + @layer active { + .control:active { + --ni-private-step-icon-border-color: ${borderHoverColor}; + --ni-private-step-icon-background-color: ${fillSelectedColor}; + --ni-private-step-icon-background-size: var(--ni-private-step-icon-background-full-size); + --ni-private-step-line-color: ${borderHoverColor}; + } + + :host([severity="error"]) .control:active { + --ni-private-step-icon-border-color: ${failColor}; + --ni-private-step-icon-background-color: rgb(from ${failColor} r g b / 30%); + --ni-private-step-line-color: ${failColor}; + } + + :host([severity="warning"]) .control:active { + --ni-private-step-icon-border-color: ${warningColor}; + --ni-private-step-icon-background-color: rgb(from ${warningColor} r g b / 30%); + --ni-private-step-line-color: ${warningColor}; + } + + :host([severity="success"]) .control:active { + --ni-private-step-icon-border-color: ${passColor}; + --ni-private-step-icon-background-color: rgb(from ${passColor} r g b / 30%); + --ni-private-step-line-color: ${passColor}; + } + + :host([selected]) .control:active { + --ni-private-step-icon-color: ${buttonLabelFontColor}; + --ni-private-step-icon-border-color: ${borderHoverColor}; + --ni-private-step-icon-background-color: rgb(from ${borderHoverColor} r g b / 30%); + --ni-private-step-line-color: ${borderHoverColor}; + } + + .control:active .icon { + border-width: 2px; + } + + .control:active .icon::before { + outline-width: 0px; + outline-offset: 0px; + } + + .control:active .line { + transform: scale(1, 1); + } + } + + @layer disabled { + :host([disabled]) .control { + cursor: default; + color: ${buttonLabelDisabledFontColor}; + --ni-private-step-icon-color: ${buttonLabelFontColor}; + --ni-private-step-icon-border-color: transparent; + --ni-private-step-icon-background-color: rgba(${borderRgbPartialColor}, 0.1); + --ni-private-step-icon-background-size: var(--ni-private-step-icon-background-full-size); + --ni-private-step-line-color: rgba(${borderRgbPartialColor}, 0.1); + } + + :host([disabled]) .line { + transform: scale(1, 1); + } + } + + @layer top { + @media (prefers-reduced-motion) { + .control { + transition-duration: 0s; + } + } + } +`; diff --git a/packages/nimble-components/src/patterns/step/types.ts b/packages/nimble-components/src/patterns/step/types.ts new file mode 100644 index 0000000000..4159ef7053 --- /dev/null +++ b/packages/nimble-components/src/patterns/step/types.ts @@ -0,0 +1,24 @@ +import type { SeverityPattern } from '../severity/types'; +import type { StepInternals } from './models/step-internals'; + +export interface StepPattern extends SeverityPattern { + /** + * Whether or not the step is disabled. + */ + disabled: boolean; + + /** + * Whether or not the step is readOnly. + */ + readOnly: boolean; + + /** + * Whether or not the step is selected. + */ + selected: boolean; + + /** + * @internal Internal step state set by the stepper + */ + stepInternals: StepInternals; +} diff --git a/packages/nimble-components/src/step/index.ts b/packages/nimble-components/src/step/index.ts index 425ff12c6c..7cdc306431 100644 --- a/packages/nimble-components/src/step/index.ts +++ b/packages/nimble-components/src/step/index.ts @@ -1,6 +1,15 @@ -import { DesignSystem, FoundationElement } from '@ni/fast-foundation'; +import { + Button as FoundationButton, + type ButtonOptions, + DesignSystem +} from '@ni/fast-foundation'; +import { attr, nullableNumberConverter } from '@ni/fast-element'; import { styles } from './styles'; import { template } from './template'; +import type { StepPattern } from '../patterns/step/types'; +import { mixinSeverityPattern } from '../patterns/severity/types'; +import { StepInternals } from '../patterns/step/models/step-internals'; +import { StepSeverity } from './types'; declare global { interface HTMLElementTagNameMap { @@ -11,9 +20,46 @@ declare global { /** * A nimble-styled step for a stepper */ -export class Step extends FoundationElement {} +export class Step extends mixinSeverityPattern(FoundationButton) implements StepPattern { + /** + * @public + * @remarks + * HTML Attribute: disabled + */ + @attr() + public severity: StepSeverity = StepSeverity.default; -const nimbleStep = Step.compose({ + /** + * @public + * @remarks + * HTML Attribute: readonly + */ + @attr({ attribute: 'readonly', mode: 'boolean' }) + public readOnly = false; + + /** + * @public + * @remarks + * HTML Attribute: selected + */ + @attr({ mode: 'boolean' }) + public selected = false; + + /** + * @public + * @remarks + * HTML Attribute: tabindex + */ + @attr({ attribute: 'tabindex', converter: nullableNumberConverter }) + public override tabIndex!: number; + + /** + * @internal + */ + public readonly stepInternals = new StepInternals(); +} + +const nimbleStep = Step.compose({ baseName: 'step', template, styles diff --git a/packages/nimble-components/src/step/styles.ts b/packages/nimble-components/src/step/styles.ts index 5d190a3e55..cfaff82a26 100644 --- a/packages/nimble-components/src/step/styles.ts +++ b/packages/nimble-components/src/step/styles.ts @@ -1,6 +1,14 @@ import { css } from '@ni/fast-element'; -import { display } from '../utilities/style/display'; +import { styles as stepStyles } from '../patterns/step/styles'; export const styles = css` - ${display('flex')} + ${stepStyles} + ${'' /* Button specific styles */} + @layer base { + .control { + font: inherit; + border: none; + text-align: start; + } + } `; diff --git a/packages/nimble-components/src/step/template.ts b/packages/nimble-components/src/step/template.ts index 0619d62721..0b9b7be5f6 100644 --- a/packages/nimble-components/src/step/template.ts +++ b/packages/nimble-components/src/step/template.ts @@ -1,6 +1,72 @@ -import { html } from '@ni/fast-element'; +import { html, ref, slotted, ViewTemplate } from '@ni/fast-element'; +import { endSlotTemplate, startSlotTemplate, type ButtonOptions, type FoundationElementTemplate } from '@ni/fast-foundation'; import type { Step } from '.'; +import { severityTextTemplate } from '../patterns/severity/template'; -export const template = html` - +export const template: FoundationElementTemplate< +ViewTemplate, +ButtonOptions +> = (context, definition) => html` + `; diff --git a/packages/nimble-components/src/step/types.ts b/packages/nimble-components/src/step/types.ts new file mode 100644 index 0000000000..f601c78ced --- /dev/null +++ b/packages/nimble-components/src/step/types.ts @@ -0,0 +1,4 @@ +import { Severity } from '../patterns/severity/types'; + +export const StepSeverity = Severity; +export type StepSeverity = (typeof StepSeverity)[keyof typeof StepSeverity]; diff --git a/packages/nimble-components/src/stepper/styles.ts b/packages/nimble-components/src/stepper/styles.ts index 5d190a3e55..1f9ec2198c 100644 --- a/packages/nimble-components/src/stepper/styles.ts +++ b/packages/nimble-components/src/stepper/styles.ts @@ -2,5 +2,10 @@ import { css } from '@ni/fast-element'; import { display } from '../utilities/style/display'; export const styles = css` - ${display('flex')} + ${display('inline-flex')} + + :host { + border: none; + gap: 0px; + } `; diff --git a/packages/nimble-components/src/stepper/template.ts b/packages/nimble-components/src/stepper/template.ts index 18e48e11a4..811cebd372 100644 --- a/packages/nimble-components/src/stepper/template.ts +++ b/packages/nimble-components/src/stepper/template.ts @@ -1,6 +1,8 @@ import { html } from '@ni/fast-element'; import type { Stepper } from '.'; +import { devicePixelRatio } from '../utilities/models/device-pixel-ratio'; export const template = html` - + + `; diff --git a/packages/nimble-components/src/stepper/types.ts b/packages/nimble-components/src/stepper/types.ts new file mode 100644 index 0000000000..78a0016e47 --- /dev/null +++ b/packages/nimble-components/src/stepper/types.ts @@ -0,0 +1,10 @@ +/** + * Orientation of steppers. + * @public + */ +export const StepperOrientation = { + horizontal: undefined, + vertical: 'vertical' +} as const; + +export type StepperOrientation = (typeof StepperOrientation)[keyof typeof StepperOrientation]; diff --git a/packages/nimble-components/src/utilities/models/device-pixel-ratio.ts b/packages/nimble-components/src/utilities/models/device-pixel-ratio.ts new file mode 100644 index 0000000000..847ac1bea2 --- /dev/null +++ b/packages/nimble-components/src/utilities/models/device-pixel-ratio.ts @@ -0,0 +1,22 @@ +import { observable } from '@ni/fast-element'; + +/** + * Observable class to subscribe to changes in the page's device pixel ratio + * Based on: https://frontendmasters.com/blog/obsessing-over-smooth-radial-gradient-disc-edges/#the-less-code-and-more-flexible-js-solution + */ +class DevicePixelRatio { + @observable + public current!: number; + + public constructor() { + const update = (): void => { + this.current = window.devicePixelRatio; + window + .matchMedia(`(resolution: ${this.current}x)`) + .addEventListener('change', update, { once: true }); + }; + update(); + } +} + +export const devicePixelRatio = new DevicePixelRatio(); diff --git a/packages/storybook/src/nimble/anchor-step/anchor-step-matrix.stories.ts b/packages/storybook/src/nimble/anchor-step/anchor-step-matrix.stories.ts new file mode 100644 index 0000000000..59fd322b65 --- /dev/null +++ b/packages/storybook/src/nimble/anchor-step/anchor-step-matrix.stories.ts @@ -0,0 +1,78 @@ +import type { StoryFn, Meta } from '@storybook/html-vite'; +import { html, ViewTemplate } from '@ni/fast-element'; +import { anchorStepTag } from '@ni/nimble-components/dist/esm/anchor-step'; +import { stepperTag } from '@ni/nimble-components/dist/esm/stepper'; +import { + createMatrix, + sharedMatrixParameters, + createMatrixThemeStory, + cartesianProduct, + createMatrixInteractionsFromStates +} from '../../utilities/matrix'; +import { createStory } from '../../utilities/storybook'; +import { hiddenWrapper } from '../../utilities/hidden'; +import { textCustomizationWrapper } from '../../utilities/text-customization'; +import { severityStates, type SeverityStates } from '../stepper/types'; + +const metadata: Meta = { + title: 'Tests/Anchor Step', + parameters: { + ...sharedMatrixParameters() + } +}; + +export default metadata; + +const component = ( + [severityName, severity]: SeverityStates, +): ViewTemplate => html` + <${stepperTag}> + <${anchorStepTag} + href="#" + style="width: 200px;" + severity-text="Severity Text" + severity="${() => severity}" + > +
${() => severityName}
+
Subtitle
+ 😀 + + +`; + +export const themeMatrix: StoryFn = createMatrixThemeStory( + createMatrix(component, [ + severityStates + ]) +); + +const interactionStatesHover = cartesianProduct([ + severityStates +] as const); + +const interactionStates = cartesianProduct([ + severityStates +] as const); + +export const interactionsThemeMatrix: StoryFn = createMatrixThemeStory( + createMatrixInteractionsFromStates(component, { + hover: interactionStatesHover, + hoverActive: interactionStates, + active: interactionStates, + focus: interactionStates + }) +); + +export const hidden: StoryFn = createStory( + hiddenWrapper( + html`<${anchorStepTag} hidden + >Hidden Anchor Button` + ) +); + +export const textCustomized: StoryFn = createMatrixThemeStory( + textCustomizationWrapper( + html`<${anchorStepTag}>Anchor Button` + ) +); diff --git a/packages/storybook/src/nimble/step/step-matrix.stories.ts b/packages/storybook/src/nimble/step/step-matrix.stories.ts new file mode 100644 index 0000000000..3c578ea945 --- /dev/null +++ b/packages/storybook/src/nimble/step/step-matrix.stories.ts @@ -0,0 +1,77 @@ +import type { StoryFn, Meta } from '@storybook/html-vite'; +import { html, ViewTemplate } from '@ni/fast-element'; +import { stepTag } from '@ni/nimble-components/dist/esm/step'; +import { stepperTag } from '@ni/nimble-components/dist/esm/stepper'; +import { + createMatrix, + sharedMatrixParameters, + createMatrixThemeStory, + cartesianProduct, + createMatrixInteractionsFromStates +} from '../../utilities/matrix'; +import { createStory } from '../../utilities/storybook'; +import { hiddenWrapper } from '../../utilities/hidden'; +import { textCustomizationWrapper } from '../../utilities/text-customization'; +import { severityStates, type SeverityStates } from '../stepper/types'; + +const metadata: Meta = { + title: 'Tests/Step', + parameters: { + ...sharedMatrixParameters() + } +}; + +export default metadata; + +const component = ( + [severityName, severity]: SeverityStates, +): ViewTemplate => html` + <${stepperTag}> + <${stepTag} + style="width: 200px;" + severity-text="Severity Text" + severity="${() => severity}" + > +
${() => severityName}
+
Subtitle
+ 😀 + + +`; + +export const themeMatrix: StoryFn = createMatrixThemeStory( + createMatrix(component, [ + severityStates + ]) +); + +const interactionStatesHover = cartesianProduct([ + severityStates +] as const); + +const interactionStates = cartesianProduct([ + severityStates +] as const); + +export const interactionsThemeMatrix: StoryFn = createMatrixThemeStory( + createMatrixInteractionsFromStates(component, { + hover: interactionStatesHover, + hoverActive: interactionStates, + active: interactionStates, + focus: interactionStates + }) +); + +export const hidden: StoryFn = createStory( + hiddenWrapper( + html`<${stepTag} hidden + >Hidden Anchor Button` + ) +); + +export const textCustomized: StoryFn = createMatrixThemeStory( + textCustomizationWrapper( + html`<${stepTag}>Anchor Button` + ) +); diff --git a/packages/storybook/src/nimble/stepper/stepper.stories.ts b/packages/storybook/src/nimble/stepper/stepper.stories.ts index 88edbd1539..310277e27d 100644 --- a/packages/storybook/src/nimble/stepper/stepper.stories.ts +++ b/packages/storybook/src/nimble/stepper/stepper.stories.ts @@ -1,33 +1,188 @@ -import { html } from '@ni/fast-element'; +import { html, when } from '@ni/fast-element'; import type { Meta, StoryObj } from '@storybook/html-vite'; import { stepperTag } from '@ni/nimble-components/dist/esm/stepper'; import { anchorStepTag } from '@ni/nimble-components/dist/esm/anchor-step'; import { stepTag } from '@ni/nimble-components/dist/esm/step'; +import { AnchorStepSeverity } from '@ni/nimble-components/dist/esm/anchor-step/types'; +import { StepSeverity } from '@ni/nimble-components/dist/esm/step/types'; import { apiCategory, createUserSelectedThemeStory, - incubatingWarning + disabledDescription, + // incubatingWarning } from '../../utilities/storybook'; import { ExampleStepType } from './types'; +import { hrefDescription } from '../patterns/anchor/anchor-docs'; -interface StepperArgs { - stepType: ExampleStepType; -} - -const metadata: Meta = { +const metadata: Meta = { title: 'Incubating/Stepper', parameters: { actions: {} }, +}; +export default metadata; + +const severityTextDescription = 'A message to be displayed explaining the current severity level of the control. Always visible when set with style based on control severity.'; + +interface AnchorStepArgs { + href: string; + disabled: boolean; + severity: keyof typeof AnchorStepSeverity; + severityText: string; + title: string; + subtitle: string; + selected: boolean; +} +export const anchorStep: StoryObj = { render: createUserSelectedThemeStory(html` - ${incubatingWarning({ + <${stepperTag}> + <${anchorStepTag} + href="${x => (x.href === '' ? undefined : x.href)}" + ?disabled="${x => x.disabled}" + severity="${x => AnchorStepSeverity[x.severity]}" + severity-text="${x => x.severityText}" + selected="${x => x.selected}" + style="width:150px" + > + 😀 + ${when(x => x.title, html`
${x => x.title}
`)} + ${when(x => x.subtitle, html`
${x => x.subtitle}
`)} + + + `), + argTypes: { + href: { + description: hrefDescription({ + componentName: 'anchor step', + includeDisable: false + }), + table: { category: apiCategory.attributes } + }, + disabled: { + description: disabledDescription({ + componentName: 'anchor step' + }), + table: { category: apiCategory.attributes } + }, + severity: { + options: Object.keys(AnchorStepSeverity), + control: { type: 'radio' }, + description: 'Severity of the step', + table: { category: apiCategory.attributes } + }, + severityText: { + description: severityTextDescription, + table: { category: apiCategory.attributes } + }, + title: { + description: 'step title', + table: { category: apiCategory.slots } + }, + subtitle: { + description: 'step subtitle', + table: { category: apiCategory.slots } + }, + selected: { + description: 'Styles that indicate the control selected.', + table: { category: apiCategory.attributes } + }, + }, + args: { + href: 'https://nimble.ni.dev', + disabled: false, + severity: 'default', + severityText: 'Helper message', + title: 'Title', + subtitle: 'Subtitle', + selected: false, + } +}; + +interface StepArgs { + disabled: boolean; + severity: keyof typeof StepSeverity; + severityText: string; + title: string; + subtitle: string; + selected: boolean; +} +export const step: StoryObj = { + render: createUserSelectedThemeStory(html` + <${stepperTag}> + <${stepTag} + ?disabled="${x => x.disabled}" + severity="${x => StepSeverity[x.severity]}" + severity-text="${x => x.severityText}" + selected="${x => x.selected}" + style="width:150px" + > + 1 + ${when(x => x.title, html`
${x => x.title}
`)} + ${when(x => x.subtitle, html`
${x => x.subtitle}
`)} + + + `), + argTypes: { + disabled: { + description: disabledDescription({ + componentName: 'anchor step' + }), + table: { category: apiCategory.attributes } + }, + severity: { + options: Object.keys(StepSeverity), + control: { type: 'radio' }, + description: 'Severity of the step', + table: { category: apiCategory.attributes } + }, + severityText: { + description: severityTextDescription, + table: { category: apiCategory.attributes } + }, + title: { + description: 'step title', + table: { category: apiCategory.slots } + }, + subtitle: { + description: 'step subtitle', + table: { category: apiCategory.slots } + }, + selected: { + description: 'Styles that indicate the control selected.', + table: { category: apiCategory.attributes } + }, + }, + args: { + disabled: false, + severity: 'default', + severityText: 'Helper message', + title: 'Title', + subtitle: 'Subtitle', + selected: false, + } +}; + +interface StepperArgs { + stepType: ExampleStepType; +} + +export const stepper: StoryObj = { + render: createUserSelectedThemeStory(html` + ${'' /* incubatingWarning({ componentName: stepperTag, statusLink: 'https://github.com/ni/nimble/issues/624' - })} + }) */} <${stepperTag}> - <${anchorStepTag}> - <${stepTag}> - + <${anchorStepTag} severity="success" href="#" severity-text="Error Description" style="width:150px"> + 😀 +
Title
+
Subtitle
+ + <${stepTag} severity="success" severity-text="Error Description" style="width:150px"> + 😀 +
Title
+
Subtitle
+ `), argTypes: { stepType: { @@ -41,7 +196,3 @@ const metadata: Meta = { stepType: ExampleStepType.simple } }; - -export default metadata; - -export const stepper: StoryObj = {}; diff --git a/packages/storybook/src/nimble/stepper/types.ts b/packages/storybook/src/nimble/stepper/types.ts index 447614b7f6..efa845c54a 100644 --- a/packages/storybook/src/nimble/stepper/types.ts +++ b/packages/storybook/src/nimble/stepper/types.ts @@ -1,6 +1,16 @@ +import { Severity } from '@ni/nimble-components/dist/esm/patterns/severity/types'; + export const ExampleStepType = { simple: 'simple', many: 'many', wide: 'wide' } as const; export type ExampleStepType = (typeof ExampleStepType)[keyof typeof ExampleStepType]; + +export const severityStates = [ + ['Default', Severity.default], + ['Error', Severity.error], + ['Warning', Severity.warning], + ['Success', Severity.success] +] as const; +export type SeverityStates = (typeof severityStates)[number];