diff --git a/docs-app/package.json b/docs-app/package.json index f84381b2f..5ea093030 100644 --- a/docs-app/package.json +++ b/docs-app/package.json @@ -118,6 +118,7 @@ }, "dependencies": { "@crowdstrike/ember-oss-docs": "^1.1.0", + "@crowdstrike/ember-toucan-core": "^0.0.0", "@ember/test-waiters": "^3.0.2", "@embroider/router": "^1.9.0", "dompurify": "^2.4.0", diff --git a/docs/components/button/demo/base-demo.md b/docs/components/button/demo/base-demo.md new file mode 100644 index 000000000..3642b3eb2 --- /dev/null +++ b/docs/components/button/demo/base-demo.md @@ -0,0 +1,15 @@ +```hbs template + +``` + +```js component +import Component from '@glimmer/component'; +import { action } from '@ember/object'; + +export default class extends Component { + @action + onClick(e) { + alert('Button clicked!'); + } +} +``` diff --git a/docs/components/button/index.md b/docs/components/button/index.md new file mode 100644 index 000000000..8ded8d45a --- /dev/null +++ b/docs/components/button/index.md @@ -0,0 +1,148 @@ +# Button + +Buttons are clickable elements used primarily for actions. Button content expresses what action will occur when the user interacts with it. + +## Variants + +You can customize the appearance of the button with the `@variant` component argument. + +
+ + + + + + +
+ +## Type + +To provide the [type attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#attr-type) to the Button, the `@type` component argument is used. + +```hbs + +``` + +## Handling Clicks + +To handle click events use the `@onClick` component argument. + +```hbs + +``` + +## Disabled State + +`aria-disabled` is used over the `disabled` attribute so that screenreaders can still focus the element. To set the button as disabled, use `@isDisabled`. + +```hbs + +``` + +A disabled named block is provided so that users can optionally render additional content when the button is disabled. + +```hbs + +``` + +
+{{#each (array "primary" "secondary" "destructive" "link" "quiet" "bare") as |variant|}} + +{{/each}} + +
+ +## Loading State + +Button exposes an `@isLoading` component argument. The button content will be only visible to screenreaders. + +```hbs + +``` + +A loading named block is also provided for providing custom loading content. + +```hbs + +``` + +
+{{#each (array "primary" "secondary" "destructive" "link" "quiet" "bare") as |variant|}} + +{{/each}} + +
diff --git a/docs/demos/demo-a.md b/docs/demos/demo-a.md deleted file mode 100644 index 4b8e9a30b..000000000 --- a/docs/demos/demo-a.md +++ /dev/null @@ -1 +0,0 @@ -

Hi there!

diff --git a/ember-toucan-core/package.json b/ember-toucan-core/package.json index 1f1b47247..acab87ad7 100644 --- a/ember-toucan-core/package.json +++ b/ember-toucan-core/package.json @@ -40,6 +40,7 @@ "tailwindcss": "^2.2.15 || ^3.0.0" }, "dependencies": { + "@babel/runtime": "^7.20.7", "@embroider/addon-shim": "^1.0.0" }, "devDependencies": { @@ -107,7 +108,9 @@ "version": 2, "type": "addon", "main": "addon-main.cjs", - "app-js": {} + "app-js": { + "./components/button/index.js": "./dist/_app_/components/button/index.js" + } }, "exports": { ".": "./dist/index.js", diff --git a/ember-toucan-core/src/components/button/index.hbs b/ember-toucan-core/src/components/button/index.hbs new file mode 100644 index 000000000..82ec78ad6 --- /dev/null +++ b/ember-toucan-core/src/components/button/index.hbs @@ -0,0 +1,20 @@ + \ No newline at end of file diff --git a/ember-toucan-core/src/components/button/index.ts b/ember-toucan-core/src/components/button/index.ts new file mode 100644 index 000000000..73418963e --- /dev/null +++ b/ember-toucan-core/src/components/button/index.ts @@ -0,0 +1,93 @@ +import Component from '@glimmer/component'; +import { assert } from '@ember/debug'; +import { action } from '@ember/object'; + +const VALID_VARIANTS = [ + 'bare', + 'destructive', + 'link', + 'primary', + 'quiet', + 'secondary', +] as const; + +export type ButtonVariant = (typeof VALID_VARIANTS)[number]; + +const STYLES = { + base: [ + 'focusable', + 'inline-flex', + 'items-center', + 'justify-center', + 'rounded-sm', + 'transition', + 'truncate', + 'type-md-medium', + ], + variants: { + bare: ['focusable'], + destructive: ['focusable-destructive', 'interactive-destructive'], + link: ['font-normal', 'interactive-link', 'underline'], + primary: ['interactive-primary'], + quiet: ['font-normal', 'interactive-quiet'], + secondary: ['interactive-normal'], + }, +}; + +export interface ButtonSignature { + Args: { + isDisabled?: boolean; + onClick?: (event: MouseEvent) => void; + type?: 'button' | 'reset' | 'submit'; + variant?: ButtonVariant; + }; + Blocks: { default: []; disabled: []; loading: [] }; + Element: HTMLButtonElement; +} + +export default class Button extends Component { + get type() { + return this.args?.type || 'button'; + } + + get variant() { + const { variant } = this.args; + + assert( + `Invalid variant for Button: '${variant}' (allowed values: [${VALID_VARIANTS.join( + ', ' + )}])`, + VALID_VARIANTS.includes(variant ?? 'primary') + ); + + return variant || 'primary'; + } + + get styles() { + if (this.variant === 'bare') { + return STYLES.variants.bare; + } + + const buttonStyles = [...STYLES.base, ...STYLES.variants[this.variant]]; + const disabledStyles = ['interactive-disabled', 'focus:outline-none']; + + if (this.variant !== 'link') { + buttonStyles.push('px-4', 'py-1'); + } + + return this.args.isDisabled + ? [...buttonStyles, ...disabledStyles].join(' ') + : buttonStyles.join(' '); + } + + @action + onClick(event: MouseEvent) { + if (this.args.isDisabled) { + event.stopImmediatePropagation(); + + return; + } + + this.args.onClick?.(event); + } +} diff --git a/ember-toucan-core/src/template-registry.ts b/ember-toucan-core/src/template-registry.ts index 0b1c893bd..acd70bf69 100644 --- a/ember-toucan-core/src/template-registry.ts +++ b/ember-toucan-core/src/template-registry.ts @@ -1,4 +1,5 @@ +import type ButtonComponent from './components/button'; + export default interface Registry { - // TODO: put components here - Button: unknown; + Button: typeof ButtonComponent; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 914402b41..c47b44e2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,7 @@ importers: '@babel/core': ^7.19.6 '@babel/eslint-parser': ^7.19.1 '@crowdstrike/ember-oss-docs': ^1.1.0 + '@crowdstrike/ember-toucan-core': ^0.0.0 '@crowdstrike/ember-toucan-styles': ^1.0.5 '@crowdstrike/tailwind-toucan-base': ^3.3.1 '@docfy/core': ^0.5.0 @@ -111,6 +112,7 @@ importers: webpack: ^5.74.0 dependencies: '@crowdstrike/ember-oss-docs': 1.1.2_z5tbqp6wpcajf367rd44tqmo3q + '@crowdstrike/ember-toucan-core': link:../ember-toucan-core '@ember/test-waiters': 3.0.2 '@embroider/router': 1.9.0_y6i5noi7i27x4xrxls7lvmorjy dompurify: 2.4.3 @@ -220,6 +222,7 @@ importers: '@babel/plugin-proposal-decorators': ^7.17.0 '@babel/plugin-syntax-decorators': ^7.17.0 '@babel/preset-typescript': ^7.18.6 + '@babel/runtime': ^7.20.7 '@crowdstrike/ember-toucan-styles': ^1.0.5 '@embroider/addon-dev': ^2.0.0 '@embroider/addon-shim': ^1.0.0 @@ -269,6 +272,7 @@ importers: tailwindcss: ^2.2.15 typescript: ^4.7.4 dependencies: + '@babel/runtime': 7.20.7 '@embroider/addon-shim': 1.8.4 devDependencies: '@babel/core': 7.20.12 @@ -321,7 +325,7 @@ importers: prettier-plugin-ember-template-tag: 0.3.0 rollup: 2.79.1 rollup-plugin-copy: 3.4.0 - rollup-plugin-ts: 3.1.1_srcjubbzqq4n4sfsezzbmsybjy + rollup-plugin-ts: 3.1.1_t7a2vhquo4q6i5ua4mhj3qzc5u tailwindcss: 2.2.19_gbtt6ss3tbiz4yjtvdr6fbrj44 typescript: 4.9.4 @@ -3518,7 +3522,7 @@ packages: /@types/glob/8.0.0: resolution: {integrity: sha512-l6NQsDDyQUVeoTynNpC9uRvCUint/gSUXQA2euwmTuWGvPY5LSDUu6tkCtJB2SvGQlJQzLaKqcGZP4//7EDveA==} dependencies: - '@types/minimatch': 3.0.5 + '@types/minimatch': 5.1.2 '@types/node': 18.11.18 /@types/htmlbars-inline-precompile/3.0.0: @@ -3551,7 +3555,6 @@ packages: /@types/minimatch/5.1.2: resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} - dev: true /@types/node/17.0.45: resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==} @@ -14603,7 +14606,7 @@ packages: del: 5.1.0 dev: true - /rollup-plugin-ts/3.1.1_srcjubbzqq4n4sfsezzbmsybjy: + /rollup-plugin-ts/3.1.1_t7a2vhquo4q6i5ua4mhj3qzc5u: resolution: {integrity: sha512-Zm+cq11QVV6thgGdvrhz1tUQGN7pzK/jt2+6ttqfUaX1bk6QB4rFIHm/bW5O057eAXd1H8gBi47QFE0+tqMopw==} engines: {node: '>=14.9.0', npm: '>=7.0.0', pnpm: '>=3.2.0', yarn: '>=1.13'} peerDependencies: @@ -14634,6 +14637,7 @@ packages: dependencies: '@babel/core': 7.20.12 '@babel/preset-typescript': 7.18.6_@babel+core@7.20.12 + '@babel/runtime': 7.20.7 '@rollup/pluginutils': 5.0.2_rollup@2.79.1 '@wessberg/stringutil': 1.0.19 ansi-colors: 4.1.3 @@ -16918,6 +16922,7 @@ packages: postcss: ^8.2.14 tailwindcss: ^2.2.15 || ^3.0.0 dependencies: + '@babel/runtime': 7.20.7 '@crowdstrike/ember-toucan-styles': 1.0.5_xdr7vb7wj5y4t4icg77pe6vd2y '@embroider/addon-shim': 1.8.4 '@glimmer/tracking': 1.1.2 diff --git a/test-app/ember-cli-build.js b/test-app/ember-cli-build.js index 642e58f73..62454c646 100644 --- a/test-app/ember-cli-build.js +++ b/test-app/ember-cli-build.js @@ -5,7 +5,7 @@ const EmberApp = require('ember-cli/lib/broccoli/ember-app'); module.exports = function (defaults) { let app = new EmberApp(defaults, { autoImport: { - watchDependencies: ['ember-toucan-core'], + watchDependencies: ['@crowdstrike/ember-toucan-core'], }, }); diff --git a/test-app/tests/integration/components/button-test.ts b/test-app/tests/integration/components/button-test.ts new file mode 100644 index 000000000..a0368a066 --- /dev/null +++ b/test-app/tests/integration/components/button-test.ts @@ -0,0 +1,146 @@ +import { click, render, setupOnerror } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { module, test } from 'qunit'; + +import { setupRenderingTest } from 'test-app/tests/helpers'; + +module('Integration | Component | button', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + await render(hbs` + + `); + + assert.dom('button').hasText('text'); + assert.dom('button').hasNoAttribute('aria-disabled'); + assert + .dom('button') + .hasAttribute('type', 'button', 'Expected default type to be "button"'); + }); + + test('it yields a loading named block when `@isLoading={{true}}', async function (assert) { + await render(hbs` + + `); + + assert + .dom('[data-test-selector="loading"]') + .exists('Expect to have loading named block rendered'); + }); + + test('it does not render the content in the loading named block when `@isLoading={{false}}', async function (assert) { + await render(hbs` + + `); + + assert + .dom('[data-test-selector="loading"]') + .doesNotExist('Expected to NOT have loading named block rendered'); + + assert + .dom('[data-test-selector="default"]') + .exists('Expect to have default named block rendered'); + }); + + test('it yields a disabled named block when `@isDisabled={{true}}', async function (assert) { + await render(hbs` + + `); + + assert + .dom('[data-test-selector="disabled"]') + .exists('Expect to have disabled named block rendered'); + }); + + test('it does not render the content in the disabled named block when `@isDisabled={{false}}', async function (assert) { + await render(hbs` + + `); + + assert + .dom('[data-test-selector="disabled"]') + .doesNotExist('Expected to NOT have disabled named block rendered'); + + assert + .dom('[data-test-selector="default"]') + .exists('Expect to have default named block rendered'); + }); + + test('it sets `aria-disabled="true"` when `@isDisabled={{true}}', async function (assert) { + await render(hbs` + + `); + + assert.dom('button').hasAttribute('aria-disabled', 'true'); + }); + + test('it sets `type` based on `@type', async function (assert) { + await render(hbs` + + `); + + assert.dom('button').hasAttribute('type', 'submit'); + }); + + test('it calls the provided `@onClick`', async function (assert) { + this.set('onClick', () => assert.step('clicked')); + + await render(hbs` + + `); + + assert.verifySteps([]); + + await click('button'); + + assert.verifySteps(['clicked']); + }); + + test('it throws an assertion error if provided with an unsupported `@variant`', async function (assert) { + assert.expect(1); + + setupOnerror((e: Error) => { + assert.ok( + e.message.includes('Invalid variant for Button'), + 'Expected assertion error message' + ); + }); + + await render(hbs` + + `); + }); +});