diff --git a/packages/@ember/helper/index.ts b/packages/@ember/helper/index.ts
index 579aa49daf2..74548465064 100644
--- a/packages/@ember/helper/index.ts
+++ b/packages/@ember/helper/index.ts
@@ -10,6 +10,9 @@ import {
concat as glimmerConcat,
get as glimmerGet,
fn as glimmerFn,
+ and as glimmerAnd,
+ or as glimmerOr,
+ not as glimmerNot,
} from '@glimmer/runtime';
import { element as glimmerElement, uniqueId as glimmerUniqueId } from '@ember/-internals/glimmer';
import { type Opaque } from '@ember/-internals/utility-types';
@@ -511,4 +514,75 @@ export interface ElementHelper extends Opaque<'helper:element'> {}
export const uniqueId = glimmerUniqueId;
export type UniqueIdHelper = typeof uniqueId;
+/**
+ * The `{{and}}` helper evaluates arguments left to right, returning the first
+ * falsy value (using Handlebars truthiness) or the right-most value if all
+ * are truthy. Requires at least two arguments.
+ *
+ * ```js
+ * import { and } from '@ember/helper';
+ *
+ *
+ * {{if (and @isAdmin @isLoggedIn) "Welcome, admin!" "Access denied"}}
+ *
+ * ```
+ *
+ * In strict-mode (gjs/gts) templates, `and` is available as a keyword and
+ * does not need to be imported.
+ *
+ * @method and
+ * @param {unknown} args Two or more values to evaluate
+ * @return {unknown} The first falsy value or the last value
+ * @public
+ */
+export const and = glimmerAnd as unknown as AndHelper;
+export interface AndHelper extends Opaque<'helper:and'> {}
+
+/**
+ * The `{{or}}` helper evaluates arguments left to right, returning the first
+ * truthy value (using Handlebars truthiness) or the right-most value if all
+ * are falsy. Requires at least two arguments.
+ *
+ * ```js
+ * import { or } from '@ember/helper';
+ *
+ *
+ * {{if (or @hasAccess @isAdmin) "Welcome!" "No access"}}
+ *
+ * ```
+ *
+ * In strict-mode (gjs/gts) templates, `or` is available as a keyword and
+ * does not need to be imported.
+ *
+ * @method or
+ * @param {unknown} args Two or more values to evaluate
+ * @return {unknown} The first truthy value or the last value
+ * @public
+ */
+export const or = glimmerOr as unknown as OrHelper;
+export interface OrHelper extends Opaque<'helper:or'> {}
+
+/**
+ * The `{{not}}` helper returns the logical negation of its argument using
+ * Handlebars truthiness. Takes exactly one argument.
+ *
+ * ```js
+ * import { not } from '@ember/helper';
+ *
+ *
+ * {{if (not @isDisabled) "Enabled" "Disabled"}}
+ *
+ * ```
+ *
+ * In strict-mode (gjs/gts) templates, `not` is available as a keyword and
+ * does not need to be imported.
+ *
+ * @method not
+ * @param {unknown} value The value to negate
+ * @return {boolean}
+ * @public
+ */
+export const not = glimmerNot as unknown as NotHelper;
+export interface NotHelper extends Opaque<'helper:not'> {}
+
/* eslint-enable @typescript-eslint/no-empty-object-type */
diff --git a/packages/@ember/template-compiler/lib/compile-options.ts b/packages/@ember/template-compiler/lib/compile-options.ts
index c9db7a1777b..84943c55cfc 100644
--- a/packages/@ember/template-compiler/lib/compile-options.ts
+++ b/packages/@ember/template-compiler/lib/compile-options.ts
@@ -1,4 +1,4 @@
-import { fn } from '@ember/helper';
+import { and, fn, not, or } from '@ember/helper';
import { on } from '@ember/modifier';
import { assert } from '@ember/debug';
import {
@@ -25,8 +25,11 @@ function malformedComponentLookup(string: string) {
export const RUNTIME_KEYWORDS_NAME = '__ember_keywords__';
export const keywords: Record = {
+ and,
fn,
+ not,
on,
+ or,
};
function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileOptions {
diff --git a/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts b/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts
index 64503a3b5d9..45a52080a41 100644
--- a/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts
+++ b/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts
@@ -30,11 +30,29 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP
if (isFn(node, hasLocal)) {
rewriteKeyword(env, node, 'fn', '@ember/helper');
}
+ if (isAnd(node, hasLocal)) {
+ rewriteKeyword(env, node, 'and', '@ember/helper');
+ }
+ if (isOr(node, hasLocal)) {
+ rewriteKeyword(env, node, 'or', '@ember/helper');
+ }
+ if (isNot(node, hasLocal)) {
+ rewriteKeyword(env, node, 'not', '@ember/helper');
+ }
},
MustacheStatement(node: AST.MustacheStatement) {
if (isFn(node, hasLocal)) {
rewriteKeyword(env, node, 'fn', '@ember/helper');
}
+ if (isAnd(node, hasLocal)) {
+ rewriteKeyword(env, node, 'and', '@ember/helper');
+ }
+ if (isOr(node, hasLocal)) {
+ rewriteKeyword(env, node, 'or', '@ember/helper');
+ }
+ if (isNot(node, hasLocal)) {
+ rewriteKeyword(env, node, 'not', '@ember/helper');
+ }
},
},
};
@@ -68,3 +86,24 @@ function isFn(
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
return isPath(node.path) && node.path.original === 'fn' && !hasLocal('fn');
}
+
+function isAnd(
+ node: AST.MustacheStatement | AST.SubExpression,
+ hasLocal: (k: string) => boolean
+): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
+ return isPath(node.path) && node.path.original === 'and' && !hasLocal('and');
+}
+
+function isOr(
+ node: AST.MustacheStatement | AST.SubExpression,
+ hasLocal: (k: string) => boolean
+): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
+ return isPath(node.path) && node.path.original === 'or' && !hasLocal('or');
+}
+
+function isNot(
+ node: AST.MustacheStatement | AST.SubExpression,
+ hasLocal: (k: string) => boolean
+): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
+ return isPath(node.path) && node.path.original === 'not' && !hasLocal('not');
+}
diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/and-runtime-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/and-runtime-test.ts
new file mode 100644
index 00000000000..fd06ecde6cc
--- /dev/null
+++ b/packages/@glimmer-workspace/integration-tests/test/keywords/and-runtime-test.ts
@@ -0,0 +1,54 @@
+import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
+
+import { template } from '@ember/template-compiler/runtime';
+
+class KeywordAndRuntime extends RenderTest {
+ static suiteName = 'keyword helper: and (runtime)';
+
+ @test
+ 'explicit scope without import'() {
+ const compiled = template('{{if (and a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a: true, b: true }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'implicit scope (eval)'() {
+ let a = true;
+ let b = 'hello';
+
+ hide(a);
+ hide(b);
+
+ const compiled = template('{{if (and a b) "yes" "no"}}', {
+ strictMode: true,
+ eval() {
+ return eval(arguments[0]);
+ },
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'returns falsy when one arg is falsy'() {
+ const compiled = template('{{if (and a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a: true, b: 0 }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+}
+
+jitSuite(KeywordAndRuntime);
+
+const hide = (variable: unknown) => {
+ new Function(`return (${JSON.stringify(variable)});`);
+};
diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/and-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/and-test.ts
new file mode 100644
index 00000000000..d2498e2cd96
--- /dev/null
+++ b/packages/@glimmer-workspace/integration-tests/test/keywords/and-test.ts
@@ -0,0 +1,76 @@
+import { DEBUG } from '@glimmer/env';
+import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
+
+import { template } from '@ember/template-compiler';
+import { and } from '@ember/helper';
+
+class KeywordAnd extends RenderTest {
+ static suiteName = 'keyword helper: and';
+
+ @test
+ 'returns right-most value when all are truthy'() {
+ let a = 1;
+ let b = 'hello';
+ const compiled = template('{{and a b}}', {
+ strictMode: true,
+ scope: () => ({ and, a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('hello');
+ }
+
+ @test
+ 'returns first falsy value'() {
+ let a = 0;
+ let b = 'hello';
+ const compiled = template('{{and a b}}', {
+ strictMode: true,
+ scope: () => ({ and, a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('0');
+ }
+
+ @test
+ 'works as a SubExpression with if'() {
+ let a = true;
+ let b = true;
+ const compiled = template('{{if (and a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ and, a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'treats empty array as falsy'() {
+ let a = true;
+ let b: unknown[] = [];
+ const compiled = template('{{if (and a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ and, a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+
+ @test({ skip: !DEBUG })
+ 'throws if called with less than two arguments'(assert: Assert) {
+ let a = true;
+ const compiled = template('{{and a}}', {
+ strictMode: true,
+ scope: () => ({ and, a }),
+ });
+
+ assert.throws(() => {
+ this.renderComponent(compiled);
+ }, /`and` expects at least two arguments/);
+ }
+}
+
+jitSuite(KeywordAnd);
diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/not-runtime-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/not-runtime-test.ts
new file mode 100644
index 00000000000..7619735e147
--- /dev/null
+++ b/packages/@glimmer-workspace/integration-tests/test/keywords/not-runtime-test.ts
@@ -0,0 +1,52 @@
+import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
+
+import { template } from '@ember/template-compiler/runtime';
+
+class KeywordNotRuntime extends RenderTest {
+ static suiteName = 'keyword helper: not (runtime)';
+
+ @test
+ 'explicit scope without import'() {
+ const compiled = template('{{if (not a) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a: false }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'implicit scope (eval)'() {
+ let a = false;
+
+ hide(a);
+
+ const compiled = template('{{if (not a) "yes" "no"}}', {
+ strictMode: true,
+ eval() {
+ return eval(arguments[0]);
+ },
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'returns no for truthy'() {
+ const compiled = template('{{if (not a) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a: 'hello' }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+}
+
+jitSuite(KeywordNotRuntime);
+
+const hide = (variable: unknown) => {
+ new Function(`return (${JSON.stringify(variable)});`);
+};
diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/not-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/not-test.ts
new file mode 100644
index 00000000000..7763db5a086
--- /dev/null
+++ b/packages/@glimmer-workspace/integration-tests/test/keywords/not-test.ts
@@ -0,0 +1,61 @@
+import { DEBUG } from '@glimmer/env';
+import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
+
+import { template } from '@ember/template-compiler';
+import { not } from '@ember/helper';
+
+class KeywordNot extends RenderTest {
+ static suiteName = 'keyword helper: not';
+
+ @test
+ 'returns true for falsy value'() {
+ let a = false;
+ const compiled = template('{{if (not a) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ not, a }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'returns false for truthy value'() {
+ let a = true;
+ const compiled = template('{{if (not a) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ not, a }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+
+ @test
+ 'works with MustacheStatement'() {
+ let a = false;
+ const compiled = template('{{not a}}', {
+ strictMode: true,
+ scope: () => ({ not, a }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('true');
+ }
+
+ @test({ skip: !DEBUG })
+ 'throws if called with more than one argument'(assert: Assert) {
+ let a = true;
+ let b = false;
+ const compiled = template('{{not a b}}', {
+ strictMode: true,
+ scope: () => ({ not, a, b }),
+ });
+
+ assert.throws(() => {
+ this.renderComponent(compiled);
+ }, /`not` expects exactly one argument/);
+ }
+}
+
+jitSuite(KeywordNot);
diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/or-runtime-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/or-runtime-test.ts
new file mode 100644
index 00000000000..cd4cc88f23e
--- /dev/null
+++ b/packages/@glimmer-workspace/integration-tests/test/keywords/or-runtime-test.ts
@@ -0,0 +1,54 @@
+import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
+
+import { template } from '@ember/template-compiler/runtime';
+
+class KeywordOrRuntime extends RenderTest {
+ static suiteName = 'keyword helper: or (runtime)';
+
+ @test
+ 'explicit scope without import'() {
+ const compiled = template('{{if (or a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a: false, b: true }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'implicit scope (eval)'() {
+ let a = false;
+ let b = 'hello';
+
+ hide(a);
+ hide(b);
+
+ const compiled = template('{{if (or a b) "yes" "no"}}', {
+ strictMode: true,
+ eval() {
+ return eval(arguments[0]);
+ },
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'returns no when all falsy'() {
+ const compiled = template('{{if (or a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a: false, b: 0 }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+}
+
+jitSuite(KeywordOrRuntime);
+
+const hide = (variable: unknown) => {
+ new Function(`return (${JSON.stringify(variable)});`);
+};
diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/or-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/or-test.ts
new file mode 100644
index 00000000000..274f1f7919f
--- /dev/null
+++ b/packages/@glimmer-workspace/integration-tests/test/keywords/or-test.ts
@@ -0,0 +1,76 @@
+import { DEBUG } from '@glimmer/env';
+import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
+
+import { template } from '@ember/template-compiler';
+import { or } from '@ember/helper';
+
+class KeywordOr extends RenderTest {
+ static suiteName = 'keyword helper: or';
+
+ @test
+ 'returns first truthy value'() {
+ let a = false;
+ let b = 'hello';
+ const compiled = template('{{or a b}}', {
+ strictMode: true,
+ scope: () => ({ or, a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('hello');
+ }
+
+ @test
+ 'returns right-most value when all are falsy'() {
+ let a = 0;
+ let b = '';
+ const compiled = template('{{or a b}}', {
+ strictMode: true,
+ scope: () => ({ or, a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('');
+ }
+
+ @test
+ 'works as a SubExpression with if'() {
+ let a = false;
+ let b = true;
+ const compiled = template('{{if (or a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ or, a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'treats empty array as falsy'() {
+ let a: unknown[] = [];
+ let b = false;
+ const compiled = template('{{if (or a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ or, a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+
+ @test({ skip: !DEBUG })
+ 'throws if called with less than two arguments'(assert: Assert) {
+ let a = true;
+ const compiled = template('{{or a}}', {
+ strictMode: true,
+ scope: () => ({ or, a }),
+ });
+
+ assert.throws(() => {
+ this.renderComponent(compiled);
+ }, /`or` expects at least two arguments/);
+ }
+}
+
+jitSuite(KeywordOr);
diff --git a/packages/@glimmer/runtime/index.ts b/packages/@glimmer/runtime/index.ts
index 9ec4eb2b603..fd4f9200cb7 100644
--- a/packages/@glimmer/runtime/index.ts
+++ b/packages/@glimmer/runtime/index.ts
@@ -31,12 +31,15 @@ export {
inTransaction,
runtimeOptions,
} from './lib/environment';
+export { and } from './lib/helpers/and';
export { array } from './lib/helpers/array';
export { concat } from './lib/helpers/concat';
export { fn } from './lib/helpers/fn';
export { get } from './lib/helpers/get';
export { hash } from './lib/helpers/hash';
export { invokeHelper } from './lib/helpers/invoke';
+export { not } from './lib/helpers/not';
+export { or } from './lib/helpers/or';
export { on } from './lib/modifiers/on';
export { renderComponent, renderMain, renderSync } from './lib/render';
export { DynamicScopeImpl, ScopeImpl } from './lib/scope';
diff --git a/packages/@glimmer/runtime/lib/helpers/and.ts b/packages/@glimmer/runtime/lib/helpers/and.ts
new file mode 100644
index 00000000000..9d5034c2f2a
--- /dev/null
+++ b/packages/@glimmer/runtime/lib/helpers/and.ts
@@ -0,0 +1,13 @@
+import { DEBUG } from '@glimmer/env';
+import { toBool } from '@glimmer/global-context';
+
+export const and = (...args: unknown[]) => {
+ if (DEBUG && args.length < 2) {
+ throw new Error(`\`and\` expects at least two arguments, but received ${args.length}.`);
+ }
+
+ for (let i = 0; i < args.length; i++) {
+ if (!toBool(args[i])) return args[i];
+ }
+ return args[args.length - 1];
+};
diff --git a/packages/@glimmer/runtime/lib/helpers/not.ts b/packages/@glimmer/runtime/lib/helpers/not.ts
new file mode 100644
index 00000000000..4b98c96d52e
--- /dev/null
+++ b/packages/@glimmer/runtime/lib/helpers/not.ts
@@ -0,0 +1,10 @@
+import { DEBUG } from '@glimmer/env';
+import { toBool } from '@glimmer/global-context';
+
+export const not = (...args: unknown[]) => {
+ if (DEBUG && args.length !== 1) {
+ throw new Error(`\`not\` expects exactly one argument, but received ${args.length}.`);
+ }
+
+ return !toBool(args[0]);
+};
diff --git a/packages/@glimmer/runtime/lib/helpers/or.ts b/packages/@glimmer/runtime/lib/helpers/or.ts
new file mode 100644
index 00000000000..84d1e924d83
--- /dev/null
+++ b/packages/@glimmer/runtime/lib/helpers/or.ts
@@ -0,0 +1,13 @@
+import { DEBUG } from '@glimmer/env';
+import { toBool } from '@glimmer/global-context';
+
+export const or = (...args: unknown[]) => {
+ if (DEBUG && args.length < 2) {
+ throw new Error(`\`or\` expects at least two arguments, but received ${args.length}.`);
+ }
+
+ for (let i = 0; i < args.length; i++) {
+ if (toBool(args[i])) return args[i];
+ }
+ return args[args.length - 1];
+};