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
+Button
+```
+
+```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.
+
+
+ Primary
+ Secondary
+ Destructive
+ Link
+ Quiet
+ Bare
+
+
+## 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
+Submit
+```
+
+## Handling Clicks
+
+To handle click events use the `@onClick` component argument.
+
+```hbs
+Click Me
+```
+
+## 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
+Disabled
+```
+
+A disabled named block is provided so that users can optionally render additional content when the button is disabled.
+
+```hbs
+
+ <:disabled>
+
+
+
+
+
+ <:default>
+ Disabled
+
+
+```
+
+
+{{#each (array "primary" "secondary" "destructive" "link" "quiet" "bare") as |variant|}}
+
+<:disabled>
+
+
+
+
+
+<:default>
+{{variant}}
+
+
+{{/each}}
+
+
+
+## Loading State
+
+Button exposes an `@isLoading` component argument. The button content will be only visible to screenreaders.
+
+```hbs
+Loading…
+```
+
+A loading named block is also provided for providing custom loading content.
+
+```hbs
+
+ <:loading>
+
+
+ <:default>
+ Loading…
+
+
+```
+
+
+{{#each (array "primary" "secondary" "destructive" "link" "quiet" "bare") as |variant|}}
+
+<:loading>
+
+
+
+<:default>
+{{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 @@
+
+ {{#if @isLoading}}
+ {{yield to="loading"}}
+ {{yield}}
+ {{else if @isDisabled}}
+
+ {{yield}}
+ {{yield to="disabled"}}
+
+ {{else}}
+ {{yield}}
+ {{/if}}
+
\ 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`
+
+ text
+
+ `);
+
+ 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`
+
+ <:loading>
+ loading state
+
+
+ `);
+
+ 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`
+
+ <:loading>
+ should not be visible since isLoading is false
+
+ <:default>
+
+
+
+ `);
+
+ 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`
+
+ <:disabled>
+ disabled state
+
+
+ `);
+
+ 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`
+
+ <:disabled>
+ should not be visible since isDisabled is false
+
+ <:default>
+
+
+
+ `);
+
+ 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`
+
+ disabled
+
+ `);
+
+ assert.dom('button').hasAttribute('aria-disabled', 'true');
+ });
+
+ test('it sets `type` based on `@type', async function (assert) {
+ await render(hbs`
+
+ button
+
+ `);
+
+ assert.dom('button').hasAttribute('type', 'submit');
+ });
+
+ test('it calls the provided `@onClick`', async function (assert) {
+ this.set('onClick', () => assert.step('clicked'));
+
+ await render(hbs`
+
+ button
+
+ `);
+
+ 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`
+
+ button
+
+ `);
+ });
+});