diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 5d39ba36505..63242ed45e4 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -872,7 +872,7 @@ export namespace Components { */ "disabled": boolean; /** - * Set to `"bold"` for a chip with vibrant, bold colors or to `"subtle"` for a chip with muted, subtle colors. Only applies to the `ionic` theme. + * Set to `"bold"` for a chip with vibrant, bold colors or to `"subtle"` for a chip with muted, subtle colors. Defaults to `"subtle"`. * @default 'subtle' */ "hue"?: 'bold' | 'subtle'; @@ -890,7 +890,8 @@ export namespace Components { */ "shape"?: 'soft' | 'round' | 'rectangular'; /** - * Set to `"small"` for a chip with less height and padding. Defaults to `"large"` for the ionic theme, and undefined for all other themes. + * Set to `"small"` for a chip with less height and padding. Defaults to `"small"`. + * @default 'large' */ "size"?: 'small' | 'large'; /** @@ -6842,7 +6843,7 @@ declare namespace LocalJSX { */ "disabled"?: boolean; /** - * Set to `"bold"` for a chip with vibrant, bold colors or to `"subtle"` for a chip with muted, subtle colors. Only applies to the `ionic` theme. + * Set to `"bold"` for a chip with vibrant, bold colors or to `"subtle"` for a chip with muted, subtle colors. Defaults to `"subtle"`. * @default 'subtle' */ "hue"?: 'bold' | 'subtle'; @@ -6860,7 +6861,8 @@ declare namespace LocalJSX { */ "shape"?: 'soft' | 'round' | 'rectangular'; /** - * Set to `"small"` for a chip with less height and padding. Defaults to `"large"` for the ionic theme, and undefined for all other themes. + * Set to `"small"` for a chip with less height and padding. Defaults to `"small"`. + * @default 'large' */ "size"?: 'small' | 'large'; /** diff --git a/core/src/components/chip/chip.base.scss b/core/src/components/chip/chip.base.scss new file mode 100644 index 00000000000..601fcef814b --- /dev/null +++ b/core/src/components/chip/chip.base.scss @@ -0,0 +1,232 @@ +@use "../../themes/mixins" as mixins; +@use "../../themes/functions.color" as colors; +@use "./chip.base.vars.scss" as vars; +@use "sass:meta"; + +// Chip: Common Styles +// -------------------------------------------------- + +:host { + /** + * @prop --background: Background of the chip + * @prop --border-radius: Border radius of the chip + * @prop --color: Color of the chip + * @prop --focus-ring-color: Color of the focus ring + * @prop --focus-ring-width: Width of the focus ring + */ + --focus-ring-color: #{vars.$chip-focus-ring-color}; + --focus-ring-width: #{vars.$chip-focus-ring-width}; + + @include mixins.font-smoothing(); + @include mixins.border-radius(var(--border-radius)); + @include mixins.margin(vars.$chip-margin); + @include mixins.padding(vars.$chip-padding-vertical, vars.$chip-padding-horizontal); + @include mixins.typography(vars.$chip-typography); + + display: inline-flex; + position: relative; + + align-items: center; + justify-content: center; + + background: var(--background); + color: var(--color); + + line-height: vars.$chip-line-height; + + cursor: pointer; + overflow: hidden; + vertical-align: middle; + box-sizing: border-box; + + gap: vars.$chip-gap; +} + +// Chip Sizes +// --------------------------------------------- + +:host(.chip-small) { + min-height: vars.$chip-size-small-height; + + font-size: vars.$chip-size-small-font-size; +} + +:host(.chip-large) { + min-height: vars.$chip-size-large-height; + + font-size: vars.$chip-size-large-font-size; +} + +// Chip Shapes +// --------------------------------------------- + +:host(.chip-soft) { + --border-radius: #{vars.$chip-border-radius-soft}; +} + +:host(.chip-round) { + --border-radius: #{vars.$chip-border-radius-round}; +} + +:host(.chip-rectangular) { + --border-radius: #{vars.$chip-border-radius-rectangular}; +} + +// Chip Hues +// --------------------------------------------- + +// Bold +:host(.chip-bold) { + --background: #{vars.$chip-hue-bold-bg}; + --color: #{vars.$chip-hue-bold-color}; +} + +:host(.chip-bold.chip-outline) { + border-color: #{vars.$chip-hue-bold-outline-border-color}; +} + +// Subtle +:host(.chip-subtle) { + --background: #{vars.$chip-hue-subtle-bg}; + --color: #{vars.$chip-hue-subtle-color}; +} + +:host(.chip-subtle.chip-outline) { + border-color: #{vars.$chip-hue-subtle-outline-border-color}; +} + +// Chip Colors +// --------------------------------------------- + +// Bold +:host(.ion-color.chip-bold) { + background: colors.current-color(base, vars.$chip-hue-bold-semantic-bg-alpha); + color: vars.$chip-hue-bold-semantic-color; +} + +:host(.ion-color.chip-bold.chip-outline) { + border-color: vars.$chip-hue-bold-semantic-outline-border-color; + + background: vars.$chip-outline-bold-semantic-bg; //native would be transparent, else it would be whatevers in ion-color.chip-bold +} + +// Subtle +:host(.ion-color.chip-subtle) { + background: colors.current-color(base, $subtle: true); + color: colors.current-color(contrast, $subtle: true); +} + +:host(.ion-color.chip-subtle.chip-outline) { + border-color: colors.current-color(shade, $subtle: true); + + background: vars.$chip-outline-subtle-semantic-bg; +} + +// Outline Chip +// --------------------------------------------- + +:host(.chip-outline) { + border-width: vars.$chip-outline-border-width; + border-style: solid; +} + +// Chip States +// --------------------------------------------- + +// Disabled +:host(.chip-disabled) { + cursor: default; + opacity: vars.$chip-state-disabled-opacity; + pointer-events: none; +} + +// Focus +:host(.ion-focused) { + --background: #{vars.$chip-focus-bg}; + + @include mixins.focused-state(var(--focus-ring-width), $color: var(--focus-ring-color)); +} + +:host(.ion-focused.ion-color) { + background: vars.$chip-focus-semantic-bg; +} + +:host(.ion-focused.chip-outline:not(.ion-color)) { + background: vars.$chip-outline-focus-bg; +} + +// Activated +:host(.ion-activated) { + --background: #{vars.$chip-activated-bg}; +} + +:host(.ion-activated.ion-color) { + background: vars.$chip-activated-semantic-bg; +} + +// Hover +@media (any-hover: hover) { + :host(:hover) { + --background: #{vars.$chip-hover-bg}; + } + + :host(.ion-color:hover) { + background: vars.$chip-hover-semantic-bg; + } + + :host(.chip-outline:not(.ion-color):hover) { + background: vars.$chip-outline-hover-bg; + } +} + +// Chip Slotted Elements +// --------------------------------------------- + +// Icon +::slotted(ion-icon) { + font-size: vars.$chip-icon-size; +} + +:host(:not(.ion-color)) ::slotted(ion-icon) { + color: vars.$chip-icon-color; +} + +::slotted(ion-icon:first-child) { + @include mixins.margin( + vars.$chip-icon-first-child-margin, + vars.$chip-icon-first-child-margin-end, + $start: vars.$chip-icon-first-child-margin + ); +} + +::slotted(ion-icon:last-child) { + @include mixins.margin(vars.$chip-icon-last-child-margin, $start: vars.$chip-icon-last-child-margin-start); +} + +// Avatar +::slotted(ion-avatar) { + // width: vars.$chip-avatar-size; + // height: vars.$chip-avatar-size; + // @error vars.$chip-avatar-size; + @if vars.$chip-avatar-size != "unset" { + width: vars.$chip-avatar-size; + height: vars.$chip-avatar-size; + } + flex-shrink: 0; +} + +::slotted(ion-avatar:first-child) { + @include mixins.margin( + vars.$chip-avatar-first-child-margin-vertical, + $end: vars.$chip-avatar-first-child-margin-end, + $start: vars.$chip-avatar-first-child-margin-start + ); +} + +::slotted(ion-avatar:last-child) { + @include mixins.margin( + vars.$chip-avatar-last-child-margin-vertical, + $end: vars.$chip-avatar-last-child-margin-end, + $start: vars.$chip-avatar-last-child-margin-start + ); +} diff --git a/core/src/components/chip/chip.base.vars.scss b/core/src/components/chip/chip.base.vars.scss new file mode 100644 index 00000000000..3251393d08c --- /dev/null +++ b/core/src/components/chip/chip.base.vars.scss @@ -0,0 +1,166 @@ +/// Chip Variables +/// --------------------------------------------- + +/// @prop - Margin of the chip +$chip-margin: var(--ion-chip-margin); + +/// @prop - Vertical padding of the chip +$chip-padding-vertical: var(--ion-chip-padding-vertical); + +/// @prop - Horizontal padding of the chip +$chip-padding-horizontal: var(--ion-chip-padding-horizontal); + +/// @prop - Gap between chip elements +$chip-gap: var(--ion-chip-gap); + +/// @prop - Line height of the chip +$chip-line-height: var(--ion-chip-line-height); + +/// @prop - Opacity of disabled chip +$chip-state-disabled-opacity: var(--ion-chip-state-disabled-opacity); + +/// @prop - Size small: Height of the chip +$chip-size-small-height: var(--ion-chip-size-small-height); + +/// @prop - Size small: Font size of the chip +$chip-size-small-font-size: var(--ion-chip-size-small-font-size); + +/// @prop - Size medium: Height of the chip +$chip-size-medium-height: var(--ion-chip-size-medium-height); + +/// @prop - Size medium: Font size of the chip +$chip-size-medium-font-size: var(--ion-chip-size-medium-font-size); + +/// @prop - Size large: Height of the chip +$chip-size-large-height: var(--ion-chip-size-large-height); + +/// @prop - Size large: Font size of the chip +$chip-size-large-font-size: var(--ion-chip-size-large-font-size); + +/// @prop - Soft chip border radius +$chip-border-radius-soft: var(--ion-chip-shape-soft-border-radius); + +/// @prop - Round chip border radius +$chip-border-radius-round: var(--ion-chip-shape-round-border-radius); + +/// @prop - Rectangular chip border radius +$chip-border-radius-rectangular: var(--ion-chip-shape-rectangular-border-radius); + +/// @prop - Subtle chip background color +$chip-hue-subtle-bg: var(--ion-chip-hue-subtle-bg); + +/// @prop - Subtle chip color +$chip-hue-subtle-color: var(--ion-chip-hue-subtle-color); + +/// @prop - Outline subtle chip border color +$chip-hue-subtle-outline-border-color: var(--ion-chip-hue-subtle-outline-border-color); + +/// @prop - Bold chip background color +$chip-hue-bold-bg: var(--ion-chip-hue-bold-bg); + +/// @prop - Bold chip color +$chip-hue-bold-color: var(--ion-chip-hue-bold-color); + +/// @prop - Outline bold chip border color +$chip-hue-bold-outline-border-color: var(--ion-chip-hue-bold-outline-border-color); + +/// @prop - Bold chip background alpha of semantic colors +$chip-hue-bold-semantic-bg-alpha: var(--ion-chip-hue-bold-semantic-bg-alpha); // only native uses this + +/// @prop - Bold chip color for semantic colors +$chip-hue-bold-semantic-color: var(--ion-chip-hue-bold-semantic-color); + +/// @prop - Outline bold chip border color for semantic colors +$chip-hue-bold-semantic-outline-border-color: var(--ion-chip-hue-bold-semantic-outline-border-color); + +/// @prop - Outline border width +$chip-outline-border-width: var(--ion-chip-variant-outline-border-width); + +/// @prop - Outline bold chip background color for semantic colors +$chip-outline-bold-semantic-bg: var(--ion-chip-hue-bold-semantic-outline-bg); + +/// @prop - Subtle chip background color for semantic colors +$chip-hue-subtle-semantic-bg: var(--ion-chip-hue-subtle-semantic-bg); + +/// @prop - Outline subtle chip background color for semantic colors +$chip-outline-subtle-semantic-bg: var(--ion-chip-hue-subtle-semantic-outline-bg); + +/// @prop - Focus ring color +$chip-focus-ring-color: var(--ion-chip-state-focus-ring-color); + +/// @prop - Focus ring width +$chip-focus-ring-width: var(--ion-chip-state-focus-ring-width); + +/// @prop - Focus ring background color +$chip-focus-bg: var(--ion-chip-state-focus-bg); + +/// @prop - Focus background color for semantic colors +$chip-focus-semantic-bg: var(--ion-chip-state-focus-semantic-bg); + +/// @prop - Outline focus background color +$chip-outline-focus-bg: var(--ion-chip-state-focus-outline-bg); + +/// @prop - Activated background color +$chip-activated-bg: var(--ion-chip-state-activated-bg); + +/// @prop - Activated background color for semantic colors +$chip-activated-semantic-bg: var(--ion-chip-state-activated-semantic-bg); + +/// @prop - Hover background color +$chip-hover-bg: var(--ion-chip-state-hover-bg); + +/// @prop - Hover background color for semantic colors +$chip-hover-semantic-bg: var(--ion-chip-state-hover-semantic-bg); + +/// @prop - Outline hover background color +$chip-outline-hover-bg: var(--ion-chip-state-hover-outline-bg); + +/// @prop - Icon size +$chip-icon-size: var(--ion-chip-icon-size); + +/// @prop - Icon color +$chip-icon-color: var(--ion-chip-icon-color); + +/// @prop - Icon margin for first child +$chip-icon-first-child-margin: var(--ion-chip-icon-first-child-margin); + +/// @prop - Icon margin end for first child +$chip-icon-first-child-margin-end: var(--ion-chip-icon-first-child-margin-end); + +/// @prop - Icon margin for last child +$chip-icon-last-child-margin: var(--ion-chip-icon-last-child-margin); + +/// @prop - Icon margin start for last child +$chip-icon-last-child-margin-start: var(--ion-chip-icon-last-child-margin-start); + +/// @prop - Avatar size +$chip-avatar-size: var(--ion-chip-avatar-size, revert-layer); + +/// @prop - Avatar margin vertical for first child +$chip-avatar-first-child-margin-vertical: var(--ion-chip-avatar-first-child-margin-vertical); + +/// @prop - Avatar margin horizontal start for first child +$chip-avatar-first-child-margin-start: var(--ion-chip-avatar-first-child-margin-start); + +/// @prop - Avatar margin horizontal end for first child +$chip-avatar-first-child-margin-end: var(--ion-chip-avatar-first-child-margin-end); + +/// @prop - Avatar margin vertical for last child +$chip-avatar-last-child-margin-vertical: var(--ion-chip-avatar-last-child-margin-vertical); + +/// @prop - Avatar margin start for last child +$chip-avatar-last-child-margin-start: var(--ion-chip-avatar-last-child-margin-start); + +/// @prop - Avatar margin end for last child +$chip-avatar-last-child-margin-end: var(--ion-chip-avatar-last-child-margin-end); + +/// @prop - Typography styles for the chip +$chip-typography: ( + font-family: var(--ion-chip-typography-font-family), + font-size: var(--ion-chip-typography-font-size), + font-weight: var(--ion-chip-typography-font-weight), + letter-spacing: var(--ion-chip-typography-letter-spacing), + line-height: var(--ion-chip-typography-line-height), + text-decoration: var(--ion-chip-typography-text-decoration), + text-transform: var(--ion-chip-typography-text-transform), +); diff --git a/core/src/components/chip/chip.tsx b/core/src/components/chip/chip.tsx index bbd30e823cb..6c92dfafa0f 100644 --- a/core/src/components/chip/chip.tsx +++ b/core/src/components/chip/chip.tsx @@ -1,6 +1,5 @@ import type { ComponentInterface } from '@stencil/core'; import { Component, Host, Prop, h } from '@stencil/core'; -import { printIonWarning } from '@utils/logging'; import { createColorClasses } from '@utils/theme'; import { getIonTheme } from '../../global/ionic-global'; @@ -12,11 +11,7 @@ import type { Color } from '../../interface'; */ @Component({ tag: 'ion-chip', - styleUrls: { - ios: 'chip.ios.scss', - md: 'chip.md.scss', - ionic: 'chip.ionic.scss', - }, + styleUrl: 'chip.base.scss', shadow: true, }) export class Chip implements ComponentInterface { @@ -41,7 +36,7 @@ export class Chip implements ComponentInterface { * Set to `"bold"` for a chip with vibrant, bold colors or to `"subtle"` for * a chip with muted, subtle colors. * - * Only applies to the `ionic` theme. + * Defaults to `"subtle"`. */ @Prop() hue?: 'bold' | 'subtle' = 'subtle'; @@ -69,29 +64,13 @@ export class Chip implements ComponentInterface { /** * Set to `"small"` for a chip with less height and padding. * - * Defaults to `"large"` for the ionic theme, and undefined for all other themes. + * Defaults to `"small"`. */ - @Prop() size?: 'small' | 'large'; - - private getSize() { - const theme = getIonTheme(this); - const { size } = this; - - if (theme === 'ionic') { - return size !== undefined ? size : 'large'; - // TODO(ROU-10695): remove the size !== undefined when we add support for - // the `ios` and `md` themes. - } else if (size !== undefined) { - printIonWarning(`The "${size}" size is not supported in the ${theme} theme.`); - } - - return undefined; - } + @Prop() size?: 'small' | 'large' = 'large'; render() { - const { hue } = this; + const { hue, size } = this; const theme = getIonTheme(this); - const size = this.getSize(); const shape = this.getShape(); return ( diff --git a/core/src/global/ionic-global.ts b/core/src/global/ionic-global.ts index 4d57b33a22d..bb6bb25f8ad 100644 --- a/core/src/global/ionic-global.ts +++ b/core/src/global/ionic-global.ts @@ -1,6 +1,6 @@ import { Build, getMode, setMode, getElement } from '@stencil/core'; import { printIonWarning } from '@utils/logging'; -import { applyGlobalTheme, getCustomTheme } from '@utils/theme'; +import { applyComponentsTheme, applyGlobalTheme, getCustomTheme } from '@utils/theme'; import type { IonicConfig, Mode, Theme } from '../interface'; import { defaultTheme as baseTheme } from '../themes/base/default.tokens'; @@ -152,9 +152,17 @@ export const initialize = (userConfig: IonicConfig = {}) => { // Apply base theme, or combine with custom theme if provided if (customTheme) { const combinedTheme = applyGlobalTheme(baseTheme, customTheme); + // Component styles must be applied after global styles in order + // to ensure CSS variables are available for components + // like the semantic colors (e.g., --ion-color-shade) + applyComponentsTheme(combinedTheme); config.set('customTheme', combinedTheme); } else { applyGlobalTheme(baseTheme); + // Component styles must be applied after global styles in order + // to ensure CSS variables are available for components + // like the semantic colors (e.g., --ion-color-shade) + applyComponentsTheme(baseTheme); config.set('customTheme', baseTheme); } diff --git a/core/src/themes/ionic/default.tokens.ts b/core/src/themes/ionic/default.tokens.ts index debcac4092b..a132208d6fc 100644 --- a/core/src/themes/ionic/default.tokens.ts +++ b/core/src/themes/ionic/default.tokens.ts @@ -1,9 +1,22 @@ +import * as colorTokens from 'outsystems-design-tokens/tokens/color scheme.json'; +import * as primitiveTokens from 'outsystems-design-tokens/tokens/primitives.json'; +import * as lightTokens from 'outsystems-design-tokens/tokens/theme/light.json'; +import * as typographyTokens from 'outsystems-design-tokens/tokens/typography.json'; + +import { currentColor, cachedResolveOsToken } from '../../utils/theme'; import { defaultTheme as baseDefaultTheme } from '../base/default.tokens'; import type { DefaultTheme } from '../themes.interfaces'; import { darkTheme } from './dark.tokens'; import { lightTheme } from './light.tokens'; +const tokenMap = { + colorTokens, + primitiveTokens, + lightTokens, + typographyTokens, +}; + export const defaultTheme: DefaultTheme = { ...baseDefaultTheme, @@ -76,4 +89,102 @@ export const defaultTheme: DefaultTheme = { xxxl: 'var(--ion-radii-1000)', xxxxl: 'var(--ion-radii-full)', }, + + components: { + IonChip: { + margin: '0px', + padding: { + vertical: primitiveTokens.scale['150'].$value, + horizontal: primitiveTokens.scale['200'].$value, + }, + typography: cachedResolveOsToken(typographyTokens.body.sm.medium.$value, tokenMap), + lineHeight: primitiveTokens.font['line-height']['full'].$value, + + // Sizes + size: { + small: { + height: primitiveTokens.scale['600'].$value, + fontSize: primitiveTokens.font['font-size']['300'].$value, + }, + large: { + height: primitiveTokens.scale['800'].$value, + fontSize: primitiveTokens.font['font-size']['350'].$value, + }, + }, + + // States + state: { + disabled: { + opacity: '0.4', + }, + focus: { + ring: { + color: lightTokens.primitives.blue['400'].$value, + width: primitiveTokens.scale['050'].$value, + }, + }, + }, + + // Shapes + shape: { + soft: { + borderRadius: primitiveTokens.scale['100'].$value, + }, + round: { + borderRadius: primitiveTokens.scale['400'].$value, + }, + rectangular: { + borderRadius: primitiveTokens.scale['0'].$value, + }, + }, + + // Hues + hue: { + bold: { + bg: cachedResolveOsToken(colorTokens.bg.neutral.bold.default, tokenMap), + color: lightTokens.primitives.base.white.$value, + + outline: { + borderColor: lightTokens.primitives.neutral['1200'].$value, + }, + + // Any of the semantic colors like primary, secondary, etc. + semantic: { + color: currentColor('contrast'), + + outline: { + borderColor: currentColor('shade'), + bg: currentColor('base'), + }, + }, + }, + subtle: { + bg: cachedResolveOsToken(lightTokens.primitives.neutral['100'], tokenMap), + color: lightTokens.primitives.neutral['800'].$value, + + outline: { + borderColor: lightTokens.primitives.neutral['300'].$value, + }, + + semantic: { + outline: { + borderColor: currentColor('shade', null, true), + bg: currentColor('base', null, true), + }, + }, + }, + }, + + // Variants + variant: { + outline: { + borderWidth: primitiveTokens.scale['025'].$value, + }, + }, + + icon: { + size: primitiveTokens.font['font-size']['400'].$value, + }, + }, + }, }; diff --git a/core/src/themes/ionic/light.tokens.ts b/core/src/themes/ionic/light.tokens.ts index 94c96baeb90..6c63c2410d0 100644 --- a/core/src/themes/ionic/light.tokens.ts +++ b/core/src/themes/ionic/light.tokens.ts @@ -1,9 +1,172 @@ +import * as colorTokens from 'outsystems-design-tokens/tokens/color scheme.json'; +import * as primitiveTokens from 'outsystems-design-tokens/tokens/primitives.json'; +import * as lightTokens from 'outsystems-design-tokens/tokens/theme/light.json'; +import * as typographyTokens from 'outsystems-design-tokens/tokens/typography.json'; + +import { cachedResolveOsToken } from '../../utils/theme'; import type { LightTheme } from '../themes.interfaces'; +const tokenMap = { + colorTokens, + lightTokens, + primitiveTokens, + typographyTokens, +}; +console.log( + 'cachedResolveOsToken(colorTokens.bg.primary.base.default, tokenMap)', + cachedResolveOsToken(colorTokens.bg.primary.base.default, tokenMap) +); export const lightTheme: LightTheme = { backgroundColor: '#ffffff', textColor: '#000000', + color: { + primary: { + bold: { + base: cachedResolveOsToken(colorTokens.bg.primary.base.default, tokenMap), + contrast: cachedResolveOsToken(colorTokens.text.inverse, tokenMap), + foreground: cachedResolveOsToken(colorTokens.text.primary, tokenMap), + shade: cachedResolveOsToken(colorTokens.bg.primary.base.press, tokenMap), + tint: cachedResolveOsToken(colorTokens.semantics.primary['600'], tokenMap), + }, + subtle: { + base: cachedResolveOsToken(colorTokens.bg.primary.subtle.default, tokenMap), + contrast: cachedResolveOsToken(colorTokens.text.primary, tokenMap), + foreground: cachedResolveOsToken(colorTokens.text.primary, tokenMap), + shade: cachedResolveOsToken(colorTokens.bg.primary.subtle.press, tokenMap), + tint: cachedResolveOsToken(colorTokens.semantics.primary['200'], tokenMap), + }, + }, + secondary: { + bold: { + base: cachedResolveOsToken(colorTokens.bg.info.base.default, tokenMap), + contrast: cachedResolveOsToken(colorTokens.text.inverse, tokenMap), + foreground: cachedResolveOsToken(colorTokens.text.primary, tokenMap), + shade: cachedResolveOsToken(colorTokens.bg.info.base.press, tokenMap), + tint: cachedResolveOsToken(colorTokens.semantics.info['700'], tokenMap), + }, + subtle: { + base: cachedResolveOsToken(colorTokens.bg.info.subtle.default, tokenMap), + contrast: cachedResolveOsToken(colorTokens.text.info, tokenMap), + foreground: cachedResolveOsToken(colorTokens.text.info, tokenMap), + shade: cachedResolveOsToken(colorTokens.bg.info.subtle.press, tokenMap), + tint: cachedResolveOsToken(colorTokens.semantics.info['200'], tokenMap), + }, + }, + tertiary: { + bold: { + base: cachedResolveOsToken(lightTokens.primitives.violet['700'], tokenMap), + contrast: cachedResolveOsToken(colorTokens.text.inverse, tokenMap), + foreground: cachedResolveOsToken(lightTokens.primitives.violet['700'], tokenMap), + shade: cachedResolveOsToken(lightTokens.primitives.violet['800'], tokenMap), + tint: cachedResolveOsToken(colorTokens.semantics.primary['600'], tokenMap), + }, + subtle: { + base: cachedResolveOsToken(lightTokens.primitives.violet['100'], tokenMap), + contrast: cachedResolveOsToken(lightTokens.primitives.violet['700'], tokenMap), + foreground: cachedResolveOsToken(lightTokens.primitives.violet['700'], tokenMap), + shade: cachedResolveOsToken(lightTokens.primitives.violet['300'], tokenMap), + tint: cachedResolveOsToken(lightTokens.primitives.violet['200'], tokenMap), + }, + }, + success: { + bold: { + base: cachedResolveOsToken(colorTokens.bg.success.base.default, tokenMap), + contrast: cachedResolveOsToken(colorTokens.text.inverse, tokenMap), + foreground: cachedResolveOsToken(colorTokens.text.success, tokenMap), + shade: cachedResolveOsToken(colorTokens.bg.success.base.press, tokenMap), + tint: cachedResolveOsToken(colorTokens.semantics.success['800'], tokenMap), + }, + subtle: { + base: cachedResolveOsToken(colorTokens.bg.success.subtle.default, tokenMap), + contrast: cachedResolveOsToken(colorTokens.text.success, tokenMap), + foreground: cachedResolveOsToken(colorTokens.text.success, tokenMap), + shade: cachedResolveOsToken(colorTokens.bg.success.subtle.press, tokenMap), + tint: cachedResolveOsToken(colorTokens.semantics.success['200'], tokenMap), + }, + }, + warning: { + bold: { + base: cachedResolveOsToken(colorTokens.bg.warning.base.default, tokenMap), + contrast: cachedResolveOsToken(colorTokens.text.inverse, tokenMap), + foreground: cachedResolveOsToken(colorTokens.text.warning, tokenMap), + shade: cachedResolveOsToken(colorTokens.bg.warning.base.press, tokenMap), + tint: cachedResolveOsToken(colorTokens.semantics.warning['800'], tokenMap), + }, + subtle: { + base: cachedResolveOsToken(colorTokens.bg.warning.subtle.default, tokenMap), + contrast: cachedResolveOsToken(colorTokens.text.warning, tokenMap), + foreground: cachedResolveOsToken(colorTokens.text.warning, tokenMap), + shade: cachedResolveOsToken(colorTokens.bg.warning.subtle.press, tokenMap), + tint: cachedResolveOsToken(colorTokens.semantics.warning['200'], tokenMap), + }, + }, + danger: { + bold: { + base: cachedResolveOsToken(colorTokens.bg.danger.base.default, tokenMap), + contrast: cachedResolveOsToken(colorTokens.text.inverse, tokenMap), + foreground: cachedResolveOsToken(colorTokens.text.danger, tokenMap), + shade: cachedResolveOsToken(colorTokens.bg.danger.base.press, tokenMap), + tint: cachedResolveOsToken(colorTokens.semantics.danger['700'], tokenMap), + }, + subtle: { + base: cachedResolveOsToken(colorTokens.bg.danger.subtle.default, tokenMap), + contrast: cachedResolveOsToken(colorTokens.text.danger, tokenMap), + foreground: cachedResolveOsToken(colorTokens.text.danger, tokenMap), + shade: cachedResolveOsToken(colorTokens.bg.danger.subtle.press, tokenMap), + tint: cachedResolveOsToken(colorTokens.semantics.danger['200'], tokenMap), + }, + }, + light: { + bold: { + base: cachedResolveOsToken(lightTokens.primitives.base.white, tokenMap), + contrast: cachedResolveOsToken(colorTokens.text.default, tokenMap), + foreground: cachedResolveOsToken(colorTokens.text.default, tokenMap), + shade: cachedResolveOsToken(lightTokens.primitives.neutral['600'], tokenMap), + tint: cachedResolveOsToken(lightTokens.primitives.neutral['400'], tokenMap), + }, + subtle: { + base: cachedResolveOsToken(colorTokens.bg.neutral.subtlest.default, tokenMap), + contrast: cachedResolveOsToken(colorTokens.text.default, tokenMap), + foreground: cachedResolveOsToken(colorTokens.text.default, tokenMap), + shade: cachedResolveOsToken(colorTokens.bg.neutral.subtlest.press, tokenMap), + tint: cachedResolveOsToken(lightTokens.primitives.neutral['100'], tokenMap), + }, + }, + medium: { + bold: { + base: cachedResolveOsToken(colorTokens.bg.neutral.bold.default, tokenMap), + contrast: cachedResolveOsToken(colorTokens.text.inverse, tokenMap), + foreground: cachedResolveOsToken(colorTokens.text.default, tokenMap), + shade: cachedResolveOsToken(colorTokens.bg.neutral.bold.press, tokenMap), + tint: cachedResolveOsToken(lightTokens.primitives.neutral['900'], tokenMap), + }, + subtle: { + base: cachedResolveOsToken(colorTokens.bg.neutral.subtle.default, tokenMap), + contrast: cachedResolveOsToken(colorTokens.text.subtlest, tokenMap), + foreground: cachedResolveOsToken(colorTokens.text.default, tokenMap), + shade: cachedResolveOsToken(colorTokens.bg.neutral.subtle.press, tokenMap), + tint: cachedResolveOsToken(lightTokens.primitives.neutral['100'], tokenMap), + }, + }, + dark: { + bold: { + base: cachedResolveOsToken(colorTokens.bg.neutral.boldest.default, tokenMap), + contrast: cachedResolveOsToken(colorTokens.text.inverse, tokenMap), + foreground: cachedResolveOsToken(colorTokens.text.default, tokenMap), + shade: cachedResolveOsToken(colorTokens.bg.neutral.boldest.press, tokenMap), + tint: cachedResolveOsToken(lightTokens.primitives.neutral['1100'], tokenMap), + }, + subtle: { + base: cachedResolveOsToken(colorTokens.bg.neutral.subtle.default, tokenMap), + contrast: cachedResolveOsToken(colorTokens.text.subtle, tokenMap), + foreground: cachedResolveOsToken(colorTokens.text.default, tokenMap), + shade: cachedResolveOsToken(colorTokens.bg.neutral.subtle.press, tokenMap), + tint: cachedResolveOsToken(lightTokens.primitives.neutral['100'], tokenMap), + }, + }, + }, + components: { IonCard: { background: '#ffffff', diff --git a/core/src/themes/ios/default.tokens.ts b/core/src/themes/ios/default.tokens.ts index 266191a1cb8..b46a7a910c7 100644 --- a/core/src/themes/ios/default.tokens.ts +++ b/core/src/themes/ios/default.tokens.ts @@ -1,9 +1,21 @@ +import { rgba, currentColor, clamp } from '../../utils/theme'; import { defaultTheme as baseDefaultTheme } from '../base/default.tokens'; import type { DefaultTheme } from '../themes.interfaces'; import { darkTheme } from './dark.tokens'; import { lightTheme } from './light.tokens'; +const colors = { + backgroundColor: 'var(--ion-background-color, #fff)', + backgroundColorRgb: 'var(--ion-background-color-rgb, 255, 255, 255)', + textColor: 'var(--ion-text-color, #000)', + textColorRgb: 'var(--ion-text-color-rgb, 0, 0, 0)', +}; + +const fontSizes = { + chipBase: 14, +}; + export const defaultTheme: DefaultTheme = { ...baseDefaultTheme, @@ -71,4 +83,124 @@ export const defaultTheme: DefaultTheme = { xxxl: 'var(--ion-radii-500)', xxxxl: 'var(--ion-radii-full)', }, + + components: { + IonChip: { + margin: '4px', + padding: { + vertical: '6px', + horizontal: '12px', + }, + + // Sizes + size: { + small: { + height: '24px', + fontSize: clamp('12px', `${(fontSizes.chipBase - 2) / 16}rem`, '20px'), + }, + large: { + height: '32px', + fontSize: clamp('14px', `${(fontSizes.chipBase + 2) / 16}rem`, '24px'), + }, + }, + + // States + state: { + disabled: { + opacity: '0.4', + }, + focus: { + bg: rgba(colors.textColorRgb, 0.16), + semanticBg: currentColor('base', 0.12), + outlineBg: rgba(colors.textColorRgb, 0.04), + }, + activated: { + bg: rgba(colors.textColorRgb, 0.2), + semanticBg: currentColor('base', 0.16), + }, + hover: { + bg: rgba(colors.textColorRgb, 0.16), + semanticBg: currentColor('base', 0.12), + outlineBg: rgba(colors.textColorRgb, 0.04), + }, + }, + + // Shapes + shape: { + soft: { + borderRadius: 'var(--ion-radii-250)', + }, + round: { + borderRadius: 'var(--ion-radii-full)', + }, + rectangular: { + borderRadius: 'var(--ion-radii-0)', + }, + }, + + // Hues + hue: { + bold: { + bg: rgba(colors.textColorRgb, 0.12), + color: rgba(colors.textColorRgb, 0.87), + + outline: { + borderColor: rgba(colors.textColorRgb, 0.32), + }, + + // Any of the semantic colors like primary, secondary, etc. + semantic: { + bgAlpha: '0.08', + color: currentColor('shade'), + + outline: { + borderColor: currentColor('base', 0.32), + bg: 'transparent', + }, + }, + }, + subtle: { + bg: rgba(colors.textColorRgb, 0.04), + color: rgba(colors.textColorRgb, 0.87), + + outline: { + borderColor: rgba(colors.textColorRgb, 0.32), + }, + + semantic: { + outline: { + borderColor: currentColor('shade'), + bg: 'transparent', + }, + }, + }, + }, + + // Variants + variant: { + outline: { + borderWidth: '1px', + }, + }, + + icon: { + size: `${20 / fontSizes.chipBase}em`, + color: rgba(colors.textColorRgb, 0.54), + firstChildMargin: '-4px', + firstChildMarginEnd: '8px', + lastChildMargin: '-4px', + lastChildMarginStart: '8px', + }, + + avatar: { + size: `${24 / fontSizes.chipBase}em`, + firstChildMarginVertical: '-4px', + firstChildMarginStart: '-8px', + firstChildMarginEnd: '8px', + lastChildMarginVertical: '-4px', + lastChildMarginStart: '8px', + lastChildMarginEnd: '-8px', + }, + }, + }, }; diff --git a/core/src/themes/md/default.tokens.ts b/core/src/themes/md/default.tokens.ts index 7dcff7c0445..bcd6e5d8a43 100644 --- a/core/src/themes/md/default.tokens.ts +++ b/core/src/themes/md/default.tokens.ts @@ -1,9 +1,21 @@ +import { rgba, currentColor } from '../../utils/theme'; import { defaultTheme as baseDefaultTheme } from '../base/default.tokens'; import type { DefaultTheme } from '../themes.interfaces'; import { darkTheme } from './dark.tokens'; import { lightTheme } from './light.tokens'; +const colors = { + backgroundColor: 'var(--ion-background-color, #fff)', + backgroundColorRgb: 'var(--ion-background-color-rgb, 255, 255, 255)', + textColor: 'var(--ion-text-color, #000)', + textColorRgb: 'var(--ion-text-color-rgb, 0, 0, 0)', +}; + +const fontSizes = { + chipBase: 14, +}; + export const defaultTheme: DefaultTheme = { ...baseDefaultTheme, @@ -76,4 +88,124 @@ export const defaultTheme: DefaultTheme = { xxxl: 'var(--ion-radii-900)', xxxxl: 'var(--ion-radii-full)', }, + + components: { + IonChip: { + margin: '4px', + padding: { + vertical: '6px', + horizontal: '12px', + }, + + // Sizes + size: { + small: { + height: '24px', + fontSize: `${(fontSizes.chipBase - 2) / 16}rem`, + }, + large: { + height: '32px', + fontSize: `${(fontSizes.chipBase + 2) / 16}rem`, + }, + }, + + // States + state: { + disabled: { + opacity: '0.4', + }, + focus: { + bg: rgba(colors.textColorRgb, 0.16), + semanticBg: currentColor('base', 0.12), + outlineBg: rgba(colors.textColorRgb, 0.04), + }, + activated: { + bg: rgba(colors.textColorRgb, 0.2), + semanticBg: currentColor('base', 0.16), + }, + hover: { + bg: rgba(colors.textColorRgb, 0.16), + semanticBg: currentColor('base', 0.12), + outlineBg: rgba(colors.textColorRgb, 0.04), + }, + }, + + // Shapes + shape: { + soft: { + borderRadius: 'var(--ion-radii-200)', + }, + round: { + borderRadius: 'var(--ion-radii-full)', + }, + rectangular: { + borderRadius: 'var(--ion-radii-0)', + }, + }, + + // Hues + hue: { + bold: { + bg: rgba(colors.textColorRgb, 0.12), + color: rgba(colors.textColorRgb, 0.87), + + outline: { + borderColor: rgba(colors.textColorRgb, 0.32), + }, + + // Any of the semantic colors like primary, secondary, etc. + semantic: { + bgAlpha: '0.08', + color: currentColor('shade'), + + outline: { + borderColor: currentColor('base', 0.32), + bg: 'transparent', + }, + }, + }, + subtle: { + bg: rgba(colors.textColorRgb, 0.04), + color: rgba(colors.textColorRgb, 0.87), + + outline: { + borderColor: rgba(colors.textColorRgb, 0.32), + }, + + semantic: { + outline: { + borderColor: currentColor('shade'), + bg: 'transparent', + }, + }, + }, + }, + + // Variants + variant: { + outline: { + borderWidth: '1px', + }, + }, + + icon: { + size: `${20 / fontSizes.chipBase}em`, + color: rgba(colors.textColorRgb, 0.54), + firstChildMargin: '-4px', + firstChildMarginEnd: '8px', + lastChildMargin: '-4px', + lastChildMarginStart: '8px', + }, + + avatar: { + size: `${24 / fontSizes.chipBase}em`, + firstChildMarginVertical: '-4px', + firstChildMarginStart: '-8px', + firstChildMarginEnd: '8px', + lastChildMarginVertical: '-4px', + lastChildMarginStart: '8px', + lastChildMarginEnd: '-8px', + }, + }, + }, }; diff --git a/core/src/themes/mixins.scss b/core/src/themes/mixins.scss index d21c89d2e9f..85fdfabd4e6 100644 --- a/core/src/themes/mixins.scss +++ b/core/src/themes/mixins.scss @@ -1,4 +1,5 @@ @use "./functions.string" as string; +@use "sass:meta"; /** * A heuristic that applies CSS to tablet @@ -609,3 +610,42 @@ } } } + +// Mixin that applies focus styles to interactive elements. +// +// Example: +// +// ```scss +// :host(.ion-focused) .toggle-icon { +// @include mixins.focused-state(); +// } +// ``` +// -------------------------------------------------- +@mixin focused-state($width: null, $style: solid, $color: null, $addOffset: true) { + @if $width == null or $color == null { + outline: none; + } @else { + outline: $width $style $color; + + @if $addOffset { + outline-offset: $width; + } + } +} + +// Typography mixin to be used with typography scss variables (ionic.vars.scss) +// +// ex: @include typography($ion-heading-h3-medium); +// +// -------------------------------------------------- +@mixin typography($properties) { + font-family: map-get($properties, font-family); + font-size: map-get($properties, font-size); + font-weight: map-get($properties, font-weight); + + letter-spacing: map-get($properties, letter-spacing); + line-height: map-get($properties, line-height); + + text-decoration: map-get($properties, text-decoration); + text-transform: map-get($properties, text-transform); +} diff --git a/core/src/themes/themes.interfaces.ts b/core/src/themes/themes.interfaces.ts index 6e5e8157e29..3b785010d82 100644 --- a/core/src/themes/themes.interfaces.ts +++ b/core/src/themes/themes.interfaces.ts @@ -211,11 +211,7 @@ export type BaseTheme = { }; // COMPONENT OVERRIDES - components?: { - [key: string]: { - [key: string]: string; - }; - }; + components?: Components; // COLOR TOKENS color?: { @@ -261,3 +257,141 @@ export type DefaultTheme = BaseTheme & { config?: IonicConfig; }; + +type Components = { + IonChip?: { + margin: string | number; + + padding?: { + vertical: string | number; + horizontal: string | number; + }; + + gap?: string | number; + lineHeight?: string | number; + typography?: { + [key: string]: string | number; + }; + + // Sizes + size: { + small: { + height: string | number; + fontSize: string | number; + }; + large: { + height: string | number; + fontSize: string | number; + }; + }; + + // States + state: { + disabled: { + opacity: string | number; + }; + focus: { + ring?: { + color: string; + width?: string | number; + }; + bg?: string; + semanticBg?: string; + outlineBg?: string; + }; + activated?: { + bg: string; + semanticBg: string; + }; + hover?: { + bg: string; + semanticBg: string; + outlineBg: string; + }; + }; + + // Shapes + shape: { + soft: { + borderRadius: string | number; + }; + round: { + borderRadius: string | number; + }; + rectangular: { + borderRadius: string | number; + }; + }; + + // Hues + hue: { + bold: { + bg: string; + color: string; + + outline: { + borderColor: string; + }; + + // Any of the semantic colors like primary, secondary, etc. + semantic: { + bgAlpha?: string; + color: string; + + outline: { + borderColor: string; + bg?: string; + }; + }; + }; + subtle: { + bg: string; + color: string; + + outline: { + borderColor: string; + bg?: string; + }; + + semantic: { + outline: { + borderColor: string; + bg?: string; + }; + }; + }; + }; + + // Variants + variant: { + outline: { + borderWidth: string | number; + }; + }; + + icon: { + size: string | number; + color?: string; + firstChildMargin?: string | number; + firstChildMarginEnd?: string | number; + lastChildMargin?: string | number; + lastChildMarginStart?: string | number; + }; + + avatar?: { + size: string | number | null; + firstChildMarginVertical?: string | number; + firstChildMarginStart?: string | number; + firstChildMarginEnd?: string | number; + lastChildMarginVertical?: string | number; + lastChildMarginStart?: string | number; + lastChildMarginEnd?: string | number; + }; + }; + + IonCard?: any; + IonItem?: any; + IonTabBar?: any; + IonModal?: any; + IonToolbar?: any; +}; diff --git a/core/src/utils/test/theme.spec.ts b/core/src/utils/test/theme.spec.ts index 9c68d95510d..f21ade1fadd 100644 --- a/core/src/utils/test/theme.spec.ts +++ b/core/src/utils/test/theme.spec.ts @@ -5,7 +5,7 @@ import { CardContent } from '../../components/card-content/card-content'; import { Chip } from '../../components/chip/chip'; import { generateColorClasses, - generateComponentThemeCSS, + generateComponentsThemeCSS, generateCSSVars, generateGlobalThemeCSS, getClassList, @@ -557,27 +557,77 @@ describe('generateGlobalThemeCSS', () => { }); }); -describe('generateComponentThemeCSS', () => { - it('should generate component theme CSS for a given theme', () => { - const IonChip = { - hue: { - subtle: { - bg: 'red', - color: 'white', - borderColor: 'black', +describe('generateComponentsThemeCSS', () => { + it('should generate component theme CSS for a given theme with a single component', () => { + const components = { + IonChip: { + hue: { + subtle: { + bg: 'red', + color: 'white', + borderColor: 'black', + }, + bold: { + bg: 'blue', + color: 'white', + borderColor: 'black', + }, + }, + }, + }; + + const css = generateComponentsThemeCSS(components).replace(/\s/g, ''); + + const expectedCSS = ` + ion-chip { + --ion-chip-hue-subtle-bg: red; + --ion-chip-hue-subtle-color: white; + --ion-chip-hue-subtle-border-color: black; + --ion-chip-hue-bold-bg: blue; + --ion-chip-hue-bold-color: white; + --ion-chip-hue-bold-border-color: black; + } + `.replace(/\s/g, ''); + + expect(css).toBe(expectedCSS); + }); + + it('should generate component theme CSS for a given theme with multiple components', () => { + const components = { + IonChip: { + hue: { + subtle: { + bg: 'red', + color: 'white', + borderColor: 'black', + }, + bold: { + bg: 'blue', + color: 'white', + borderColor: 'black', + }, }, - bold: { - bg: 'blue', - color: 'white', - borderColor: 'black', + }, + IonBadge: { + hue: { + subtle: { + bg: 'green', + color: 'white', + borderColor: 'black', + }, + bold: { + bg: 'blue', + color: 'white', + borderColor: 'black', + }, }, }, }; - const css = generateComponentThemeCSS(IonChip, 'chip').replace(/\s/g, ''); + const css = generateComponentsThemeCSS(components).replace(/\s/g, ''); const expectedCSS = ` - :host(.chip-themed) { + ion-chip { --ion-chip-hue-subtle-bg: red; --ion-chip-hue-subtle-color: white; --ion-chip-hue-subtle-border-color: black; @@ -585,10 +635,27 @@ describe('generateComponentThemeCSS', () => { --ion-chip-hue-bold-color: white; --ion-chip-hue-bold-border-color: black; } + + ion-badge { + --ion-badge-hue-subtle-bg: green; + --ion-badge-hue-subtle-color: white; + --ion-badge-hue-subtle-border-color: black; + --ion-badge-hue-bold-bg: blue; + --ion-badge-hue-bold-color: white; + --ion-badge-hue-bold-border-color: black; + } `.replace(/\s/g, ''); expect(css).toBe(expectedCSS); }); + + it('should not generate CSS variables for an empty components object', () => { + const components = {}; + + const css = generateComponentsThemeCSS(components); + + expect(css).toBe(''); + }); }); describe('generateColorClasses', () => { diff --git a/core/src/utils/theme.ts b/core/src/utils/theme.ts index aeab9823ec2..e2247169da4 100644 --- a/core/src/utils/theme.ts +++ b/core/src/utils/theme.ts @@ -81,10 +81,7 @@ export const generateCSSVars = (theme: any, prefix: string = CSS_PROPS_PREFIX): return []; } - // if key is camelCase, convert to kebab-case - if (key.match(/([a-z])([A-Z])/g)) { - key = key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); - } + key = convertToKebabCase(key); // Do not generate CSS variables for excluded keys const excludedKeys = ['name', 'enabled', 'config']; @@ -264,13 +261,8 @@ export const injectCSS = (css: string, target: Element | ShadowRoot = document.h * @returns The generated CSS string */ export const generateGlobalThemeCSS = (theme: any): string => { - if (typeof theme !== 'object' || Array.isArray(theme)) { - console.warn('generateGlobalThemeCSS: Invalid theme object provided', theme); - return ''; - } - - if (Object.keys(theme).length === 0) { - console.warn('generateGlobalThemeCSS: Empty theme object provided'); + const themeValidity = checkThemeValidity(theme, 'generateGlobalThemeCSS'); + if (!themeValidity) { return ''; } @@ -358,17 +350,48 @@ export const applyGlobalTheme = (baseTheme: any, userTheme?: any): any => { * Generates component's themed CSS class with CSS variables * from its theme object * @param componentTheme The component's object to generate CSS for (e.g., IonChip { }) - * @param componentName The component name without any prefixes (e.g., 'chip') + * @param components An object mapping component names (e.g. `IonChip`) to a nested + * design-token configuration. Each configuration can contain arbitrary levels of + * token groups (such as `size`, `state`, `shape`, `variant`, etc.), where leaf values + * are CSS-compatible values. The structure is recursively flattened into CSS custom + * properties using kebab-case keys and an `--ion--` prefix. + * + * Example: + * ```json + * { + * IonChip: { + * size: { small: { height: "24px" } }, + * state: { disabled: { opacity: "0.4" } } + * } + * } + * ``` + * + * Becomes: + * ```css + * :root ion-chip { + * --ion-chip-size-small-height: 24px; + * --ion-chip-state-disabled-opacity: 0.4; + * } + * ``` * @returns string containing the component's themed CSS variables */ -export const generateComponentThemeCSS = (componentTheme: any, componentName: string): string => { - const cssProps = generateCSSVars(componentTheme, `${CSS_PROPS_PREFIX}${componentName}-`); +export const generateComponentsThemeCSS = (components: any): string => { + let css = ''; - return ` - :host(.${componentName}-themed) { - ${cssProps} - } - `; + for (const [component, componentTokens] of Object.entries(components)) { + const componentTag = convertToKebabCase(component); + const vars = generateCSSVars(componentTokens, `--${componentTag}-`); + + const componentBlock = ` + ${componentTag} { + ${vars} + } + `; + + css += componentBlock; + } + + return css; }; /** @@ -376,34 +399,50 @@ export const generateComponentThemeCSS = (componentTheme: any, componentName: st * @param element The element to apply the theme to * @returns true if theme was applied, false otherwise */ -export const applyComponentTheme = (element: HTMLElement): void => { - const customTheme = (window as any).Ionic?.config?.get?.('customTheme'); +export const applyComponentsTheme = (theme: any): any => { + const themeValidity = checkThemeValidity(theme, 'applyComponentsTheme'); + if (!themeValidity) { + return ''; + } + + // grab all the default components from theme + const { components } = theme; + + // check if there is no components then return + if (!components) { + return ''; + } - // Convert 'ION-CHIP' to 'ion-chip' and split into parts - const parts = element.tagName.toLowerCase().split('-'); + injectCSS(generateComponentsThemeCSS(components)); + return components; - // Get the component name 'chip' from the second part - const componentName = parts[1]; + // const customTheme = (window as any).Ionic?.config?.get?.('customTheme'); - // Convert to 'IonChip' by capitalizing each part - const themeLookupName = parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(''); + // // Convert 'ION-CHIP' to 'ion-chip' and split into parts + // const parts = element.tagName.toLowerCase().split('-'); - // Get the component theme from the global custom theme if it exists - const componentTheme = customTheme?.components?.[themeLookupName]; + // // Get the component name 'chip' from the second part + // const componentName = parts[1]; - if (componentTheme) { - // Add the theme class to the element (e.g., 'chip-themed') - const themeClass = `${componentName}-themed`; - element.classList.add(themeClass); + // // Convert to 'IonChip' by capitalizing each part + // const themeLookupName = parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(''); - // Generate CSS custom properties inside a theme class selector - const css = generateComponentThemeCSS(componentTheme, componentName); + // // Get the component theme from the global custom theme if it exists + // const componentTheme = customTheme?.components?.[themeLookupName]; - // Inject styles into shadow root if available, - // otherwise into the element itself - const root = element.shadowRoot ?? element; - injectCSS(css, root); - } + // if (componentTheme) { + // // Add the theme class to the element (e.g., 'chip-themed') + // const themeClass = `${componentName}-themed`; + // element.classList.add(themeClass); + + // // Generate CSS custom properties inside a theme class selector + // const css = generateComponentsThemeCSS(componentTheme, componentName); + + // // Inject styles into shadow root if available, + // // otherwise into the element itself + // const root = element.shadowRoot ?? element; + // injectCSS(css, root); + // } }; /** @@ -471,3 +510,166 @@ export const mix = (baseColor: string, mixColor: string, weight: string): string const toHex = (n: number) => n.toString(16).padStart(2, '0'); return `#${toHex(r)}${toHex(g)}${toHex(b)}`; }; + +/** + * Converts a string to kebab-case + * + * @param str The string to convert (e.g., 'IonChip') + * @returns The kebab-case string (e.g., 'ion-chip') + */ +const convertToKebabCase = (str: string): string => { + // It's already kebab-case + if (str.indexOf('-') !== -1) { + return str.toLowerCase(); + } + + return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); +}; + +const checkThemeValidity = (theme: any, source: string): boolean => { + if (typeof theme !== 'object' || Array.isArray(theme)) { + console.warn(`${source}: Invalid theme object provided`, theme); + return false; + } + + if (Object.keys(theme).length === 0) { + console.warn(`${source}: Empty theme object provided`); + return false; + } + + return true; +}; + +/** + * Mimics the Sass `rgba` function logic to construct CSS rgba() values. + * + * @param colorRgb The RGB color components as a string (e.g., '255, 0, 0'). + * @param alpha The opacity value (0 to 1). + * @returns A string containing the CSS rgba() function call. + */ +export function rgba(colorRgb: string, alpha: number | string): string { + // This directly constructs the rgba() function call using the provided values. + return `rgba(${colorRgb}, ${alpha})`; +} + +/** + * Mimics the Ionic Framework `current-color` function logic to construct CSS color values. + * + * @param variation The color variation (e.g., 'primary', 'secondary', 'base'). + * @param alpha The opacity value (0 to 1). If null, returns the full color variable. + * @param subtle If true, uses the '--ion-color-subtle-' prefix. + * @returns A string containing the CSS value (e.g., 'var(--ion-color-primary)' or 'rgba(var(--ion-color-primary-rgb), 0.16)'). + */ +export function currentColor(variation: string, alpha: number | string | null = null, subtle: boolean = false): string { + // 1. Determine the base CSS variable name + const variable = subtle ? `--ion-color-subtle-${variation}` : `--ion-color-${variation}`; + + // 2. Handle the case where no alpha is provided + if (alpha === null) { + // Corresponds to: @return var(#{$variable}); + return `var(${variable})`; + } else { + // 3. Handle the case where alpha is provided + // Corresponds to: @return rgba(var(#{$variable}-rgb), #{$alpha}); + + // NOTE: The resulting string uses the CSS variable for the RGB components + // (e.g., '255, 0, 0') and the provided alpha. + return `rgba(var(${variable}-rgb), ${alpha})`; + } +} + +/** + * Mimics the CSS `clamp` function logic. + * + * @param min The minimum value + * @param val The preferred value + * @param max The maximum value + * @returns + */ +export function clamp(min: number | string, val: number | string, max: number | string): string { + return `clamp(${min}, ${val}, ${max})`; +} + +// Create a cache to store results +const cache = new Map(); + +export const cachedResolveOsToken = (tokenPath: any, tokenMap: Record): any => { + // Use the path/object as the key + // (Note: For objects, this caches by reference) + if (cache.has(tokenPath)) { + return cache.get(tokenPath); + } + + // Use your existing resolveOsToken function with the global tokenMap + const result = resolveOsToken(tokenPath, tokenMap); + + cache.set(tokenPath, result); + return result; +}; + +export const resolveOsToken = (tokenPath: any, tokenMap: Record): any => { + // 1. Handle Objects (like Typography maps) + if (typeof tokenPath === 'object' && tokenPath !== null) { + // If it's a leaf-node token object, unwrap the $value immediately + if ('$value' in tokenPath) { + return resolveOsToken(tokenPath.$value, tokenMap); + } + + // Otherwise, it's a map of multiple tokens, resolve each key + const resolvedObject: Record = {}; + for (const [key, val] of Object.entries(tokenPath)) { + resolvedObject[key] = resolveOsToken(val, tokenMap); + } + return resolvedObject; + } + + // 2. Handle Reference Strings: "{category.path.item}" + let lookupPath = tokenPath; + let isPath = false; + + if (typeof tokenPath === 'string' && tokenPath.startsWith('{') && tokenPath.endsWith('}')) { + const reference = tokenPath.slice(1, -1).trim(); + const [refCategory, ...refPath] = reference.split('.'); + + let rootKey: string | null = null; + switch (refCategory) { + case 'semantics': + rootKey = 'colorTokens'; + break; + case 'font': + rootKey = 'primitiveTokens'; + break; + case 'primitives': + rootKey = 'lightTokens'; + break; + case 'typography': + rootKey = 'typographyTokens'; + break; + case 'scale': + rootKey = 'primitiveTokens'; + break; // Added 'scale' based on your example + } + + if (!rootKey) return tokenPath; + + // As requested, keeping refCategory in the path + lookupPath = `${rootKey}.${refCategory}.${refPath.join('.')}`; + isPath = true; + } + + // 3. ONLY run the reduce if we have confirmed this is a path to be searched + if (isPath) { + const value = lookupPath.split('.').reduce((acc: any, key: string) => { + if (acc && typeof acc === 'object' && key in acc) { + return acc[key]; + } + return undefined; + }, tokenMap); + + // Recursively resolve the result of the lookup + return resolveOsToken(value, tokenMap); + } + + // 4. If it's not a path or a reference, it's a Literal Value (Hex, Font-stack, etc.) + return tokenPath; +}; diff --git a/core/tsconfig.json b/core/tsconfig.json index 6e2271dfde6..5cefdae6512 100644 --- a/core/tsconfig.json +++ b/core/tsconfig.json @@ -34,7 +34,8 @@ "@utils/*": ["src/utils/*"], "@utils/test": ["src/utils/test/utils"], "@global/*": ["src/global/*"] - } + }, + "resolveJsonModule": true, }, "include": [ "src",