diff --git a/packages/base/structured-theme-variables.gts b/packages/base/structured-theme-variables.gts index a97fdb82bea..cbbb2bfefda 100644 --- a/packages/base/structured-theme-variables.gts +++ b/packages/base/structured-theme-variables.gts @@ -450,7 +450,6 @@ export default class ThemeVarField extends FieldDef { @field trackingNormal = contains(CSSValueField, { description: 'Specifies letter-spacing base value.', }); - // box-shadow variables @field shadow2xs = contains(CSSValueField, { description: 'Smallest shadow depth.', diff --git a/packages/boxel-ui/addon/src/components/input/index.gts b/packages/boxel-ui/addon/src/components/input/index.gts index afd28a2fe43..010a7dde5d2 100644 --- a/packages/boxel-ui/addon/src/components/input/index.gts +++ b/packages/boxel-ui/addon/src/components/input/index.gts @@ -8,6 +8,7 @@ import element from '../../helpers/element.ts'; import optional from '../../helpers/optional.ts'; import pick from '../../helpers/pick.ts'; import { and, bool, eq, not } from '../../helpers/truth-helpers.ts'; +import CheckMark from '../../icons/check-mark.gts'; import FailureBordered from '../../icons/failure-bordered.gts'; import IconSearch from '../../icons/icon-search.gts'; import LoadingIndicator from '../../icons/loading-indicator.gts'; @@ -75,7 +76,7 @@ export interface Signature { size?: 'large' | 'default'; state?: InputValidationState; type?: InputType; - value: string | number | null | undefined; + value: string | number | boolean | null | undefined; }; Element: HTMLInputElement | HTMLTextAreaElement | HTMLDivElement; } @@ -95,6 +96,14 @@ export default class BoxelInput extends Component { return this.args.type === 'search'; } + private get isCheckbox() { + return this.args.type === 'checkbox'; + } + + private get onInputPath() { + return this.isCheckbox ? 'target.checked' : 'target.value'; + } + private get type() { let type = this.args.type; @@ -141,6 +150,7 @@ export default class BoxelInput extends Component { 'input-container' has-validation=this.hasValidation is-multiline=this.isMultiline + is-checkbox=this.isCheckbox }} > {{#if (and (not @required) @optional)}} @@ -159,7 +169,7 @@ export default class BoxelInput extends Component { }} id={{this.id}} type={{this.type}} - value={{@value}} + value={{unless this.isCheckbox @value}} checked={{if (and (eq @type 'checkbox') (bool @value)) @value}} placeholder={{@placeholder}} min={{@min}} @@ -182,13 +192,16 @@ export default class BoxelInput extends Component { data-test-boxel-input data-test-boxel-input-id={{@id}} data-test-boxel-input-validation-state={{if @disabled false @state}} - {{on 'input' (pick 'target.value' (optional @onInput))}} + {{on 'input' (pick this.onInputPath (optional @onInput))}} {{on 'blur' (optional @onBlur)}} {{on 'keypress' (optional @onKeyPress)}} {{on 'focus' (optional @onFocus)}} {{on 'change' (optional @onChange)}} ...attributes /> + {{#if (and this.isCheckbox (bool @value))}} + + {{/if}} {{#if this.isSearch}}
{ animation: var(--boxel-infinite-spin-animation); } } + + /* Checkbox type */ + .input-container.is-checkbox { + --checkbox-size: var( + --boxel-checkbox-size, + var(--boxel-body-font-size) + ); + --checkbox-border-radius: var(--boxel-checkbox-border-radius, 3px); + --checkbox-border-color: var( + --boxel-checkbox-border-color, + var(--border, var(--boxel-400)) + ); + --checkbox-background: var( + --boxel-checkbox-background-color, + var(--background, transparent) + ); + --checkbox-checked-background: var( + --boxel-checkbox-checked-background-color, + var(--primary, var(--boxel-highlight)) + ); + --checkbox-checked-border-color: var( + --boxel-checkbox-checked-border-color, + var(--primary, var(--boxel-dark)) + ); + --checkbox-checkmark-color: var( + --boxel-checkbox-checkmark-color, + var(--primary-foreground, #333) + ); + --checkbox-padding: var( + --boxel-checkbox-padding, + var(--spacing, 2px) + ); + + display: inline-grid; + grid-template-columns: auto; + grid-template-areas: 'input'; + width: auto; + align-items: center; + justify-items: center; + position: relative; + } + + .input-container.is-checkbox .optional, + .input-container.is-checkbox .error-message, + .input-container.is-checkbox .helper-text, + .input-container.is-checkbox .search-icon-container, + .input-container.is-checkbox .validation-icon-container { + display: none; + } + + .checkbox-checkmark-icon { + grid-area: input; + pointer-events: none; + width: calc(var(--checkbox-size) * 0.8); + height: calc(var(--checkbox-size) * 0.8); + --icon-color: var(--checkbox-checkmark-color); + } + + .boxel-input[type='checkbox'] { + grid-area: input; + appearance: none; + /* stylelint-disable-next-line property-no-vendor-prefix */ + -webkit-appearance: none; + box-sizing: border-box; + width: var(--checkbox-size); + height: var(--checkbox-size); + min-height: unset; + padding: 0; + margin: 0; + border: 1px solid var(--checkbox-border-color); + border-radius: var(--checkbox-border-radius); + background-color: var(--checkbox-background); + box-shadow: none; + cursor: pointer; + transition: + background-color var(--boxel-transition), + border-color var(--boxel-transition); + flex-shrink: 0; + } + + .boxel-input[type='checkbox']:checked { + background-color: var(--checkbox-checked-background); + border-color: var(--checkbox-checked-border-color); + } + + .boxel-input[type='checkbox']:focus-visible { + outline: 2px solid var(--ring, var(--boxel-highlight)); + outline-offset: 2px; + border-color: var(--checkbox-border-color); + } + + .boxel-input[type='checkbox']:hover:not(:disabled):not(:checked) { + border-color: var(--boxel-dark); + } + + .boxel-input[type='checkbox']:hover:not(:disabled):checked { + border-color: var(--checkbox-checked-border-color); + } + + .boxel-input[type='checkbox']:disabled { + opacity: 0.5; + cursor: default; + } } diff --git a/packages/boxel-ui/addon/src/components/input/usage.gts b/packages/boxel-ui/addon/src/components/input/usage.gts index 2158a6279ea..517b3aae216 100644 --- a/packages/boxel-ui/addon/src/components/input/usage.gts +++ b/packages/boxel-ui/addon/src/components/input/usage.gts @@ -26,7 +26,7 @@ const validStates = Object.values(InputValidationStates); export default class InputUsage extends Component { @tracked id = 'sample-input'; - @tracked value = ''; + @tracked value: string | boolean = ''; @tracked disabled = false; @tracked readonly = false; @tracked required = false; @@ -39,6 +39,8 @@ export default class InputUsage extends Component { @tracked state: InputValidationState = 'initial'; @tracked size: 'large' | 'default' = 'default'; + @tracked isChecked = false; + defaultType = InputTypes.Text; @tracked type = this.defaultType; @@ -50,14 +52,23 @@ export default class InputUsage extends Component { @action set(ev: Event): void { let target = ev.target as HTMLInputElement; - this.value = target?.value; - this.validate(ev); + if (target.type === 'checkbox') { + this.isChecked = target.checked; + this.value = target.checked; + } else { + this.value = target?.value; + this.validate(ev); + } } @action logValue(value: any): void { console.log(value); } + @action toggleChecked(ev: Event): void { + this.isChecked = (ev.target as HTMLInputElement).checked; + } + @action validate(ev: Event): void { let target = ev.target as HTMLInputElement; if (!target.validity?.valid) { @@ -223,6 +234,83 @@ export default class InputUsage extends Component { + + <:description> + Use + @type='checkbox' + with + @value + to render a styled checkbox. Use + @onChange + to handle state changes. + + <:example> + + + <:api as |Args|> + + + + + <:cssVars as |Css|> + + + + + + + + + + <:example> @@ -351,6 +439,13 @@ export default class InputUsage extends Component { background-color: var(--sidebar); color: var(--sidebar-foreground); } + .checkbox-example-label { + display: flex; + align-items: center; + gap: var(--boxel-sp-2xs); + cursor: pointer; + font: var(--boxel-font-sm); + } :deep(.FreestyleUsageCssVar input) { display: none; } diff --git a/packages/host/app/components/card-search/section-header.gts b/packages/host/app/components/card-search/section-header.gts index fdf2afb5b3f..c8ff9f9ff65 100644 --- a/packages/host/app/components/card-search/section-header.gts +++ b/packages/host/app/components/card-search/section-header.gts @@ -1,8 +1,7 @@ -import { on } from '@ember/modifier'; import { action } from '@ember/object'; import Component from '@glimmer/component'; -import { RealmIcon } from '@cardstack/boxel-ui/components'; +import { BoxelInput, RealmIcon } from '@cardstack/boxel-ui/components'; import { eq } from '@cardstack/boxel-ui/helpers'; import type { RealmSectionInfo } from './search-content'; @@ -61,14 +60,14 @@ export default class SearchSheetSectionHeader extends Component { {{/unless}} {{#if @showOnlyLabel}} {{/if}} diff --git a/packages/host/tests/acceptance/theme-card-test.gts b/packages/host/tests/acceptance/theme-card-test.gts index 849c10cc672..e28fe026250 100644 --- a/packages/host/tests/acceptance/theme-card-test.gts +++ b/packages/host/tests/acceptance/theme-card-test.gts @@ -1,11 +1,14 @@ import { click, fillIn } from '@ember/test-helpers'; +import { getService } from '@universal-ember/test-support'; + import window from 'ember-window-mock'; import { module, test } from 'qunit'; +import { BoxelInput } from '@cardstack/boxel-ui/components'; import { dasherize } from '@cardstack/boxel-ui/helpers'; -import { Deferred } from '@cardstack/runtime-common'; +import { baseRealm, Deferred } from '@cardstack/runtime-common'; import { percySnapshot, @@ -93,6 +96,37 @@ const ROOT_STYLE_ATTRS = Object.entries(ROOT_CSS_VARS) .map(([key, val]) => [`--${dasherize(key)}`, val].join(': ')) .join('; '); +const DARK_GOLD_THEME_VARS = { + primary: '#ffd700', + primaryForeground: '#0a0f23', + border: '#3a4073', + background: '#1a1f3a', + spacing: '0.3rem', +}; + +const OCEAN_BLUE_THEME_VARS = { + primary: '#0058A3', + primaryForeground: '#FFFFFF', + border: '#003B6F', + background: '#E3F2FD', + spacing: '0.25rem', +}; + +const FOREST_GREEN_THEME_VARS = { + primary: '#2e7d32', + primaryForeground: '#FFFFFF', + border: '#1b5e20', + background: '#E8F5E9', + spacing: '0.25rem', +}; + +function hexToRgb(hex: string): string { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgb(${r}, ${g}, ${b})`; +} + const SOFT_POP_VARS = `:root { --background: oklch(0.9789 0.0082 121.6272); --foreground: oklch(0 0 0); @@ -172,11 +206,42 @@ module('Acceptance | theme-card-test', function (hooks) { setupUserSubscription(); setupAuthEndpoints(); + let loader = getService('loader-service').loader; + let cardApi: typeof import('https://cardstack.com/base/card-api'); + let booleanMod: typeof import('https://cardstack.com/base/boolean'); + cardApi = await loader.import(`${baseRealm.url}card-api`); + booleanMod = await loader.import(`${baseRealm.url}boolean`); + + let { field, contains, CardDef, Component } = cardApi; + let { default: BooleanField } = booleanMod; + + class CheckboxCard extends CardDef { + static displayName = 'Checkbox Card'; + @field isChecked = contains(BooleanField); + + static isolated = class Isolated extends Component { + + }; + } + await withCachedRealmSetup(async () => { await setupAcceptanceTestRealm({ mockMatrixUtils, contents: { ...SYSTEM_CARD_FIXTURE_CONTENTS, + 'checkbox-card.gts': { CheckboxCard }, '.realm.json': { name: 'Theme Playground', }, @@ -270,6 +335,138 @@ module('Acceptance | theme-card-test', function (hooks) { }, }, }, + 'dark-gold-theme.json': { + data: { + meta: { + adoptsFrom: { + name: 'default', + module: 'https://cardstack.com/base/structured-theme', + }, + }, + type: 'card', + attributes: { + cardInfo: { + name: 'Dark Gold', + }, + rootVariables: DARK_GOLD_THEME_VARS, + typography: { + body: { fontSize: '18px' }, + }, + }, + }, + }, + 'ocean-blue-theme.json': { + data: { + meta: { + adoptsFrom: { + name: 'default', + module: 'https://cardstack.com/base/structured-theme', + }, + }, + type: 'card', + attributes: { + cardInfo: { + name: 'Ocean Blue', + }, + rootVariables: OCEAN_BLUE_THEME_VARS, + typography: { + body: { fontSize: '14px' }, + }, + }, + }, + }, + 'forest-green-theme.json': { + data: { + meta: { + adoptsFrom: { + name: 'default', + module: 'https://cardstack.com/base/structured-theme', + }, + }, + type: 'card', + attributes: { + cardInfo: { + name: 'Forest Green', + }, + rootVariables: FOREST_GREEN_THEME_VARS, + typography: { + body: { fontSize: '16px' }, + }, + }, + }, + }, + 'checkbox-dark-gold.json': { + data: { + meta: { + adoptsFrom: { + name: 'CheckboxCard', + module: `${testRealmURL}checkbox-card`, + }, + }, + type: 'card', + attributes: { + isChecked: true, + cardInfo: { + name: 'Dark Gold Checkbox', + }, + }, + relationships: { + 'cardInfo.theme': { + links: { + self: `${testRealmURL}dark-gold-theme`, + }, + }, + }, + }, + }, + 'checkbox-ocean-blue.json': { + data: { + meta: { + adoptsFrom: { + name: 'CheckboxCard', + module: `${testRealmURL}checkbox-card`, + }, + }, + type: 'card', + attributes: { + isChecked: true, + cardInfo: { + name: 'Ocean Blue Checkbox', + }, + }, + relationships: { + 'cardInfo.theme': { + links: { + self: `${testRealmURL}ocean-blue-theme`, + }, + }, + }, + }, + }, + 'checkbox-forest-green.json': { + data: { + meta: { + adoptsFrom: { + name: 'CheckboxCard', + module: `${testRealmURL}checkbox-card`, + }, + }, + type: 'card', + attributes: { + isChecked: true, + cardInfo: { + name: 'Forest Green Checkbox', + }, + }, + relationships: { + 'cardInfo.theme': { + links: { + self: `${testRealmURL}forest-green-theme`, + }, + }, + }, + }, + }, }, }); }); @@ -588,4 +785,225 @@ module('Acceptance | theme-card-test', function (hooks) { assert.dom('[data-test-css-field]').containsText('/* No CSS defined */'); }); }); + + module('themed-checkbox', () => { + test('dark gold theme applies correct checkbox styles', async function (assert) { + let cardId = `${testRealmURL}checkbox-dark-gold`; + await visitOperatorMode({ + stacks: [[{ id: cardId, format: 'isolated' }]], + }); + + assert + .dom(`[data-test-card="${cardId}"]`) + .hasClass('boxel-card-container--themed'); + + let container = document.querySelector( + `[data-test-card="${cardId}"]`, + ); + assert.ok(container, 'themed card container is present'); + + let styleAttr = container?.getAttribute('style') ?? ''; + assert.ok( + styleAttr.includes('--primary: #ffd700'), + 'inline style includes --primary', + ); + assert.ok( + styleAttr.includes('--border: #3a4073'), + 'inline style includes --border', + ); + assert.ok( + styleAttr.includes('--background: #1a1f3a'), + 'inline style includes --background', + ); + assert.ok( + styleAttr.includes('--theme-body-font-size: 18px'), + 'inline style includes --theme-body-font-size', + ); + + assert + .dom('[data-test-checkbox-checked]') + .exists('checked checkbox renders'); + assert + .dom('[data-test-checkbox-unchecked]') + .exists('unchecked checkbox renders'); + + let checkedEl = document.querySelector( + '[data-test-checkbox-checked]', + ); + let uncheckedEl = document.querySelector( + '[data-test-checkbox-unchecked]', + ); + + assert.strictEqual( + window + .getComputedStyle(checkedEl!) + .getPropertyValue('background-color'), + hexToRgb('#ffd700'), + 'checked checkbox background-color matches --primary', + ); + assert.strictEqual( + window.getComputedStyle(uncheckedEl!).getPropertyValue('border-color'), + hexToRgb('#3a4073'), + 'unchecked checkbox border-color matches --border', + ); + assert.strictEqual( + window + .getComputedStyle(uncheckedEl!) + .getPropertyValue('background-color'), + hexToRgb('#1a1f3a'), + 'unchecked checkbox background-color matches --background', + ); + assert.strictEqual( + window.getComputedStyle(checkedEl!).getPropertyValue('width'), + '18px', + 'checkbox size matches --theme-body-font-size', + ); + }); + + test('ocean blue theme applies correct checkbox styles', async function (assert) { + let cardId = `${testRealmURL}checkbox-ocean-blue`; + await visitOperatorMode({ + stacks: [[{ id: cardId, format: 'isolated' }]], + }); + + assert + .dom(`[data-test-card="${cardId}"]`) + .hasClass('boxel-card-container--themed'); + + let container = document.querySelector( + `[data-test-card="${cardId}"]`, + ); + assert.ok(container, 'themed card container is present'); + + let styleAttr = container?.getAttribute('style') ?? ''; + assert.ok( + styleAttr.includes('--primary: #0058A3'), + 'inline style includes --primary', + ); + assert.ok( + styleAttr.includes('--border: #003B6F'), + 'inline style includes --border', + ); + assert.ok( + styleAttr.includes('--background: #E3F2FD'), + 'inline style includes --background', + ); + assert.ok( + styleAttr.includes('--theme-body-font-size: 14px'), + 'inline style includes --theme-body-font-size', + ); + + assert + .dom('[data-test-checkbox-checked]') + .exists('checked checkbox renders'); + assert + .dom('[data-test-checkbox-unchecked]') + .exists('unchecked checkbox renders'); + + let checkedEl = document.querySelector( + '[data-test-checkbox-checked]', + ); + let uncheckedEl = document.querySelector( + '[data-test-checkbox-unchecked]', + ); + + assert.strictEqual( + window + .getComputedStyle(checkedEl!) + .getPropertyValue('background-color'), + hexToRgb('#0058A3'), + 'checked checkbox background-color matches --primary', + ); + assert.strictEqual( + window.getComputedStyle(uncheckedEl!).getPropertyValue('border-color'), + hexToRgb('#003B6F'), + 'unchecked checkbox border-color matches --border', + ); + assert.strictEqual( + window + .getComputedStyle(uncheckedEl!) + .getPropertyValue('background-color'), + hexToRgb('#E3F2FD'), + 'unchecked checkbox background-color matches --background', + ); + assert.strictEqual( + window.getComputedStyle(checkedEl!).getPropertyValue('width'), + '14px', + 'checkbox size matches --theme-body-font-size', + ); + }); + + test('forest green theme applies correct checkbox styles', async function (assert) { + let cardId = `${testRealmURL}checkbox-forest-green`; + await visitOperatorMode({ + stacks: [[{ id: cardId, format: 'isolated' }]], + }); + + assert + .dom(`[data-test-card="${cardId}"]`) + .hasClass('boxel-card-container--themed'); + + let container = document.querySelector( + `[data-test-card="${cardId}"]`, + ); + assert.ok(container, 'themed card container is present'); + + let styleAttr = container?.getAttribute('style') ?? ''; + assert.ok( + styleAttr.includes('--primary: #2e7d32'), + 'inline style includes --primary', + ); + assert.ok( + styleAttr.includes('--border: #1b5e20'), + 'inline style includes --border', + ); + assert.ok( + styleAttr.includes('--background: #E8F5E9'), + 'inline style includes --background', + ); + assert.ok( + styleAttr.includes('--theme-body-font-size: 16px'), + 'inline style includes --theme-body-font-size', + ); + + assert + .dom('[data-test-checkbox-checked]') + .exists('checked checkbox renders'); + assert + .dom('[data-test-checkbox-unchecked]') + .exists('unchecked checkbox renders'); + + let checkedEl = document.querySelector( + '[data-test-checkbox-checked]', + ); + let uncheckedEl = document.querySelector( + '[data-test-checkbox-unchecked]', + ); + + assert.strictEqual( + window + .getComputedStyle(checkedEl!) + .getPropertyValue('background-color'), + hexToRgb('#2e7d32'), + 'checked checkbox background-color matches --primary', + ); + assert.strictEqual( + window.getComputedStyle(uncheckedEl!).getPropertyValue('border-color'), + hexToRgb('#1b5e20'), + 'unchecked checkbox border-color matches --border', + ); + assert.strictEqual( + window + .getComputedStyle(uncheckedEl!) + .getPropertyValue('background-color'), + hexToRgb('#E8F5E9'), + 'unchecked checkbox background-color matches --background', + ); + assert.strictEqual( + window.getComputedStyle(checkedEl!).getPropertyValue('width'), + '16px', + 'checkbox size matches --theme-body-font-size', + ); + }); + }); }); diff --git a/packages/host/tests/integration/components/operator-mode-card-catalog-test.gts b/packages/host/tests/integration/components/operator-mode-card-catalog-test.gts index ed6aef77aec..419a274a099 100644 --- a/packages/host/tests/integration/components/operator-mode-card-catalog-test.gts +++ b/packages/host/tests/integration/components/operator-mode-card-catalog-test.gts @@ -20,7 +20,7 @@ import { import OperatorMode from '@cardstack/host/components/operator-mode/container'; -import { testRealmURL } from '../../helpers'; +import { percySnapshot, testRealmURL } from '../../helpers'; import { renderComponent } from '../../helpers/render-component'; import { setupOperatorModeTests } from './operator-mode/setup'; @@ -965,6 +965,7 @@ module('Integration | operator-mode | card catalog', function (hooks) { await click(`[data-test-open-search-field]`); await fillIn(`[data-test-search-field]`, 'ma'); await waitFor('[data-test-search-sheet-show-only]'); + await percySnapshot(assert); await click('[data-test-search-sheet-show-only]'); const collapsedBlocks = document.querySelectorAll( '.search-result-block--collapsed',