Init Axo design system

This commit is contained in:
Jamie Kyle 2025-08-04 13:35:20 -07:00 committed by GitHub
commit 0d99f8bca2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 4785 additions and 210 deletions

View file

@ -317,6 +317,66 @@ module.exports = {
'func-names': 'off',
},
},
{
files: ['ts/axo/**/*.tsx'],
plugins: ['better-tailwindcss'],
settings: {
'better-tailwindcss': {
entryPoint: './ts/axo/tailwind.css',
callees: ['css'],
},
},
rules: {
// stylistic: Enforce consistent line wrapping for tailwind classes. (recommended, autofix)
'better-tailwindcss/enforce-consistent-line-wrapping': 'off',
// stylistic: Enforce a consistent order for tailwind classes. (recommended, autofix)
'better-tailwindcss/enforce-consistent-class-order': 'error',
// stylistic: Enforce consistent variable syntax. (autofix)
'better-tailwindcss/enforce-consistent-variable-syntax': 'error',
// stylistic: Enforce consistent position of the important modifier. (autofix)
'better-tailwindcss/enforce-consistent-important-position': 'error',
// stylistic: Enforce shorthand class names. (autofix)
'better-tailwindcss/enforce-shorthand-classes': 'error',
// stylistic: Remove duplicate classes. (autofix)
'better-tailwindcss/no-duplicate-classes': 'error',
// stylistic: Remove deprecated classes. (autofix)
'better-tailwindcss/no-deprecated-classes': 'off',
// stylistic: Disallow unnecessary whitespace in tailwind classes. (autofix)
'better-tailwindcss/no-unnecessary-whitespace': 'error',
// correctness: Report classes not registered with tailwindcss. (recommended)
'better-tailwindcss/no-unregistered-classes': 'error',
// correctness: Report classes that produce conflicting styles.
'better-tailwindcss/no-conflicting-classes': 'error',
// correctness: Disallow restricted classes. (autofix)
'better-tailwindcss/no-restricted-classes': [
'error',
{
restrict: [
{
pattern: '\\[#[a-fA-F0-9]{3,8}?\\]', // ex: "text-[#fff]"
message: 'No arbitrary hex values',
},
{
pattern: '\\[rgba?\\(.*\\)\\]', // ex: "text-[rgb(255,255,255)]"
message: 'No arbitrary rgb values',
},
{
pattern: '\\[hsla?\\(.*\\)\\]', // ex: "text-[hsl(255,255,255)]"
message: 'No arbitrary hsl values',
},
{
pattern: '^.*!$', // ex: "p-4!"
message: 'No !important modifiers',
},
{
pattern: '^\\*+:.*', // ex: "*:mx-0",
message: 'No child variants',
},
],
},
],
},
},
],
rules: {

View file

@ -17,6 +17,7 @@ ts/**/*.js
ts/protobuf/*.d.ts
ts/protobuf/*.js
stylesheets/manifest.css
stylesheets/tailwind.css
ts/util/lint/exceptions.json
storybook-static
build/locale-display-names.json

View file

@ -1,8 +1,19 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/** @type {import("prettier").Config} */
module.exports = {
singleQuote: true,
arrowParens: 'avoid',
trailingComma: 'es5',
overrides: [
{
files: ['./ts/axo/**.tsx'],
plugins: ['prettier-plugin-tailwindcss'],
options: {
tailwindStylesheet: './ts/axo/tailwind.css',
tailwindFunctions: ['css'],
},
},
],
};

View file

@ -9,7 +9,7 @@ const config: StorybookConfig = {
reactDocgen: false,
},
stories: ['../ts/components/**/*.stories.tsx'],
stories: ['../ts/axo/**/*.stories.tsx', '../ts/components/**/*.stories.tsx'],
addons: [
'@storybook/addon-a11y',
@ -69,6 +69,23 @@ const config: StorybookConfig = {
],
});
config.module!.rules!.push({
test: /tailwind\.css$/,
use: [
{
loader: 'postcss-loader',
options: {
postcssOptions: {
config: false,
plugins: {
'@tailwindcss/postcss': {},
},
},
},
},
],
});
config.node = { global: true };
config.externals = {

View file

@ -5,8 +5,8 @@ import '../ts/window.d.ts';
import React, { StrictMode } from 'react';
import 'sanitize.css';
import '../stylesheets/manifest.scss';
import '../ts/axo/tailwind.css';
import * as styles from './styles.scss';
import messages from '../_locales/en/messages.json';

View file

@ -4818,6 +4818,30 @@ Signal Desktop makes use of the following open source projects.
License: MIT
## radix-ui
MIT License
Copyright (c) 2022 WorkOS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## react
MIT License
@ -5546,117 +5570,6 @@ Signal Desktop makes use of the following open source projects.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## sanitize.css
# CC0 1.0 Universal
## Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator and
subsequent owner(s) (each and all, an “owner”) of an original work of
authorship and/or a database (each, a “Work”).
Certain owners wish to permanently relinquish those rights to a Work for the
purpose of contributing to a commons of creative, cultural and scientific works
(“Commons”) that the public can reliably and without fear of later claims of
infringement build upon, modify, incorporate in other works, reuse and
redistribute as freely as possible in any form whatsoever and for any purposes,
including without limitation commercial purposes. These owners may contribute
to the Commons to promote the ideal of a free culture and the further
production of creative, cultural and scientific works, or to gain reputation or
greater distribution for their Work in part through the use and efforts of
others.
For these and/or other purposes and motivations, and without any expectation of
additional consideration or compensation, the person associating CC0 with a
Work (the “Affirmer”), to the extent that he or she is an owner of Copyright
and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and
publicly distribute the Work under its terms, with knowledge of his or her
Copyright and Related Rights in the Work and the meaning and intended legal
effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights (“Copyright and
Related Rights”). Copyright and Related Rights include, but are not limited
to, the following:
1. the right to reproduce, adapt, distribute, perform, display, communicate,
and translate a Work;
2. moral rights retained by the original author(s) and/or performer(s);
3. publicity and privacy rights pertaining to a persons image or likeness
depicted in a Work;
4. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(i), below;
5. rights protecting the extraction, dissemination, use and reuse of data in
a Work;
6. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation thereof,
including any amended or successor version of such directive); and
7. other similar, equivalent or corresponding rights throughout the world
based on applicable law or treaty, and any national implementations
thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention of,
applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
unconditionally waives, abandons, and surrenders all of Affirmers Copyright
and Related Rights and associated claims and causes of action, whether now
known or unknown (including existing as well as future claims and causes of
action), in the Work (i) in all territories worldwide, (ii) for the maximum
duration provided by applicable law or treaty (including future time
extensions), (iii) in any current or future medium and for any number of
copies, and (iv) for any purpose whatsoever, including without limitation
commercial, advertising or promotional purposes (the “Waiver”). Affirmer
makes the Waiver for the benefit of each member of the public at large and
to the detriment of Affirmers heirs and successors, fully intending that
such Waiver shall not be subject to revocation, rescission, cancellation,
termination, or any other legal or equitable action to disrupt the quiet
enjoyment of the Work by the public as contemplated by Affirmers express
Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason be
judged legally invalid or ineffective under applicable law, then the Waiver
shall be preserved to the maximum extent permitted taking into account
Affirmers express Statement of Purpose. In addition, to the extent the
Waiver is so judged Affirmer hereby grants to each affected person a
royalty-free, non transferable, non sublicensable, non exclusive,
irrevocable and unconditional license to exercise Affirmers Copyright and
Related Rights in the Work (i) in all territories worldwide, (ii) for the
maximum duration provided by applicable law or treaty (including future time
extensions), (iii) in any current or future medium and for any number of
copies, and (iv) for any purpose whatsoever, including without limitation
commercial, advertising or promotional purposes (the “License”). The License
shall be deemed effective as of the date CC0 was applied by Affirmer to the
Work. Should any part of the License for any reason be judged legally
invalid or ineffective under applicable law, such partial invalidity or
ineffectiveness shall not invalidate the remainder of the License, and in
such case Affirmer hereby affirms that he or she will not (i) exercise any
of his or her remaining Copyright and Related Rights in the Work or (ii)
assert any associated claims and causes of action with respect to the Work,
in either case contrary to Affirmers express Statement of Purpose.
4. Limitations and Disclaimers.
1. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
2. Affirmer offers the Work as-is and makes no representations or warranties
of any kind concerning the Work, express, implied, statutory or
otherwise, including without limitation warranties of title,
merchantability, fitness for a particular purpose, non infringement, or
the absence of latent or other defects, accuracy, or the present or
absence of errors, whether or not discoverable, all to the greatest
extent permissible under applicable law.
3. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without
limitation any persons Copyright and Related Rights in the Work.
Further, Affirmer disclaims responsibility for obtaining any necessary
consents, permissions or other rights required for any use of the Work.
4. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to this
CC0 or use of the Work.
For more information, please see
http://creativecommons.org/publicdomain/zero/1.0/.
## semver
The ISC License

View file

@ -15,12 +15,8 @@
script-src 'self';
style-src 'self' 'unsafe-inline';"
/>
<link
href="node_modules/sanitize.css/sanitize.css"
rel="stylesheet"
type="text/css"
/>
<link href="stylesheets/manifest.css" rel="stylesheet" type="text/css" />
<link href="stylesheets/tailwind.css" rel="stylesheet" type="text/css" />
</head>
<body>
<div id="app"></div>

View file

@ -76,6 +76,13 @@
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="fonts/signal-symbols/SignalSymbolsVariable.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="images/logo-parts/base.svg"
@ -89,18 +96,13 @@
as="image"
crossorigin
/>
<link
href="node_modules/sanitize.css/sanitize.css"
rel="stylesheet"
type="text/css"
/>
<link
href="node_modules/@signalapp/quill-cjs/dist/quill.core.css"
rel="stylesheet"
type="text/css"
/>
<link href="stylesheets/manifest.css" rel="stylesheet" type="text/css" />
<link href="stylesheets/tailwind.css" rel="stylesheet" type="text/css" />
</head>
<body class="overflow-hidden">
<!-- Match ts/components/App.tsx -->

View file

@ -15,12 +15,8 @@
script-src 'self';
style-src 'self' 'unsafe-inline';"
/>
<link
href="node_modules/sanitize.css/sanitize.css"
rel="stylesheet"
type="text/css"
/>
<link href="stylesheets/manifest.css" rel="stylesheet" type="text/css" />
<link href="stylesheets/tailwind.css" rel="stylesheet" type="text/css" />
</head>
<body>
<div id="app"></div>

Binary file not shown.

View file

@ -16,12 +16,8 @@
script-src 'self';
style-src 'self' 'unsafe-inline';"
/>
<link
href="node_modules/sanitize.css/sanitize.css"
rel="stylesheet"
type="text/css"
/>
<link href="stylesheets/manifest.css" rel="stylesheet" type="text/css" />
<link href="stylesheets/tailwind.css" rel="stylesheet" type="text/css" />
</head>
<body>
<div class="app-migration-screen app-loading-screen">

View file

@ -18,7 +18,7 @@
"postinstall": "pnpm run build:acknowledgments && pnpm run electron:install-app-deps",
"postuninstall": "pnpm run build:acknowledgments",
"start": "electron .",
"generate": "npm-run-all build-protobuf build:esbuild build:icu-types build:compact-locales sass get-expire-time copy-components",
"generate": "npm-run-all build-protobuf build:esbuild build:icu-types build:compact-locales build:styles get-expire-time copy-components",
"build-release": "pnpm run build",
"sign-release": "node ts/updater/generateSignature.js",
"notarize": "echo 'No longer necessary'",
@ -30,7 +30,6 @@
"mark-unusued-strings-deleted": "ts-node ./ts/scripts/mark-unused-strings-deleted.ts",
"get-expire-time": "node ts/scripts/get-expire-time.js",
"copy-components": "node ts/scripts/copy.js",
"sass": "sass stylesheets/manifest.scss:stylesheets/manifest.css stylesheets/manifest_bridge.scss:stylesheets/manifest_bridge.css --fatal-deprecation=1.80.7",
"build-module-protobuf": "pbjs --root='signal-desktop' --target static-module --force-long --no-typeurl --no-verify --no-create --no-convert --wrap commonjs --out ts/protobuf/compiled.js protos/*.proto && pbts --no-comments --out ts/protobuf/compiled.d.ts ts/protobuf/compiled.js",
"clean-module-protobuf": "rm -f ts/protobuf/compiled.d.ts ts/protobuf/compiled.js",
"build-protobuf": "pnpm run build-module-protobuf",
@ -69,7 +68,9 @@
"dev": "pnpm run build-protobuf && cross-env SIGNAL_ENV=storybook storybook dev --port 6006",
"dev:transpile": "run-p \"check:types --watch\" dev:esbuild dev:icu-types dev:protobuf",
"dev:esbuild": "node scripts/esbuild.js --watch",
"dev:sass": "pnpm run sass --watch",
"dev:styles": "pnpm run '/^dev:styles:(sass|tailwind)$/'",
"dev:styles:sass": "pnpm run build:styles:sass --watch",
"dev:styles:tailwind": "pnpm run build:styles:tailwind --watch",
"dev:icu-types": "chokidar ./_locales/en/messages.json --initial --command \"pnpm run build:icu-types\"",
"dev:protobuf": "chokidar ./protos/**/*.proto --command \"pnpm run build-protobuf\"",
"build:storybook": "pnpm run build-protobuf && cross-env SIGNAL_ENV=storybook storybook build",
@ -87,6 +88,9 @@
"build:dev": "run-s --print-label generate build:esbuild:prod",
"build:esbuild": "node scripts/esbuild.js",
"build:esbuild:prod": "node scripts/esbuild.js --prod",
"build:styles": "pnpm run \"/^build:styles:.*/\"",
"build:styles:sass": "sass stylesheets/manifest.scss:stylesheets/manifest.css stylesheets/manifest_bridge.scss:stylesheets/manifest_bridge.css --fatal-deprecation=1.80.7",
"build:styles:tailwind": "tailwindcss -i ./ts/axo/tailwind.css -o ./stylesheets/tailwind.css",
"build:electron": "electron-builder --config.extraMetadata.environment=$SIGNAL_ENV",
"build:release": "cross-env SIGNAL_ENV=production pnpm run build:electron --config.directories.output=release",
"build:release-win32-all": "pnpm run build:release --arm64 --x64",
@ -183,6 +187,7 @@
"protobufjs": "7.3.2",
"proxy-agent": "6.4.0",
"qrcode-generator": "1.4.4",
"radix-ui": "1.4.2",
"react": "18.3.1",
"react-aria": "3.35.1",
"react-aria-components": "1.4.1",
@ -199,7 +204,6 @@
"redux-promise-middleware": "6.2.0",
"redux-thunk": "3.1.0",
"reselect": "5.1.1",
"sanitize.css": "13.0.0",
"semver": "7.6.3",
"split2": "4.2.0",
"tinykeys": "3.0.0",
@ -244,6 +248,8 @@
"@storybook/test": "8.4.4",
"@storybook/test-runner": "0.22.0",
"@storybook/types": "8.1.11",
"@tailwindcss/cli": "4.1.7",
"@tailwindcss/postcss": "4.1.7",
"@types/backbone": "1.4.22",
"@types/blueimp-load-image": "5.16.6",
"@types/chai": "4.3.16",
@ -305,6 +311,7 @@
"eslint": "8.56.0",
"eslint-config-airbnb-typescript-prettier": "5.0.0",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-better-tailwindcss": "3.7.2",
"eslint-plugin-import": "2.26.0",
"eslint-plugin-local-rules": "1.3.2",
"eslint-plugin-mocha": "10.1.0",
@ -324,7 +331,10 @@
"pixelmatch": "5.3.0",
"playwright": "1.45.0",
"pngjs": "7.0.0",
"postcss": "8.5.3",
"postcss-loader": "8.1.1",
"prettier": "3.3.3",
"prettier-plugin-tailwindcss": "0.6.11",
"protobufjs-cli": "1.1.1",
"react-devtools": "6.0.1",
"react-devtools-core": "6.0.1",
@ -340,6 +350,7 @@
"stylelint-config-recommended-scss": "14.1.0",
"stylelint-use-logical-spec": "5.0.1",
"svgo": "3.3.2",
"tailwindcss": "4.1.7",
"terser-webpack-plugin": "5.3.10",
"ts-node": "10.9.2",
"typescript": "5.6.3",
@ -379,7 +390,8 @@
"node-fetch@2.6.7": "patches/node-fetch+2.6.7.patch",
"zod@3.23.8": "patches/zod+3.23.8.patch",
"app-builder-lib": "patches/app-builder-lib.patch",
"dmg-builder": "patches/dmg-builder.patch"
"dmg-builder": "patches/dmg-builder.patch",
"eslint-plugin-better-tailwindcss": "patches/eslint-plugin-better-tailwindcss.patch"
},
"onlyBuiltDependencies": [
"@indutny/mac-screen-share",
@ -390,6 +402,7 @@
"@signalapp/ringrtc",
"@signalapp/windows-ucv",
"@swc/core",
"@tailwindcss/oxide",
"bufferutil",
"electron",
"esbuild",

View file

@ -0,0 +1,13 @@
diff --git a/lib/cjs/tailwindcss/context.async.v4.js b/lib/cjs/tailwindcss/context.async.v4.js
index 8464d9d949a0d82a53f3c6683c617b56770e5fba..ac392e6e8139f33650fc1691e5593660f87912cf 100644
--- a/lib/cjs/tailwindcss/context.async.v4.js
+++ b/lib/cjs/tailwindcss/context.async.v4.js
@@ -59,7 +59,7 @@ const createTailwindContext = async (ctx) => (0, cache_js_1.withCache)("tailwind
}
const tailwindUrl = (0, platform_js_1.isWindows)() && (0, module_js_1.isESModule)() ? (0, node_url_1.pathToFileURL)(tailwindPath).toString() : tailwindPath;
// eslint-disable-next-line eslint-plugin-typescript/naming-convention
- const { __unstable__loadDesignSystem } = await Promise.resolve(`${tailwindUrl}`).then(s => __importStar(require(s)));
+ const { __unstable__loadDesignSystem } = await import(`${tailwindUrl}`);
let css = await (0, promises_1.readFile)(ctx.tailwindConfigPath, "utf-8");
// Determine if the v4 API supports resolving `@import`
let supportsImports = false;

View file

@ -15,12 +15,8 @@
script-src 'self';
style-src 'self' 'unsafe-inline';"
/>
<link
href="node_modules/sanitize.css/sanitize.css"
rel="stylesheet"
type="text/css"
/>
<link href="stylesheets/manifest.css" rel="stylesheet" type="text/css" />
<link href="stylesheets/tailwind.css" rel="stylesheet" type="text/css" />
</head>
<body>
<div id="app"></div>

1886
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -15,12 +15,8 @@
script-src 'self';
style-src 'self' 'unsafe-inline';"
/>
<link
href="node_modules/sanitize.css/sanitize.css"
rel="stylesheet"
type="text/css"
/>
<link href="stylesheets/manifest.css" rel="stylesheet" type="text/css" />
<link href="stylesheets/tailwind.css" rel="stylesheet" type="text/css" />
</head>
<body>
<div id="app"></div>

View file

@ -15,12 +15,8 @@
script-src 'self';
style-src 'self' 'unsafe-inline';"
/>
<link
href="node_modules/sanitize.css/sanitize.css"
rel="stylesheet"
type="text/css"
/>
<link href="stylesheets/manifest.css" rel="stylesheet" type="text/css" />
<link href="stylesheets/tailwind.css" rel="stylesheet" type="text/css" />
</head>
<body>
<div id="app"></div>

View file

@ -7,6 +7,8 @@
html {
height: 100%;
cursor: inherit;
// Legacy style from sanitize.css:
overflow-wrap: break-word;
}
.light-theme {
@ -157,10 +159,13 @@ audio {
}
button {
cursor: pointer;
font-size: inherit;
-webkit-app-region: no-drag;
}
button:not(:disabled) {
cursor: pointer;
}
button.grey {
border-radius: 5px;
border: solid 1px variables.$color-gray-25;

View file

@ -20,6 +20,7 @@
padding-inline: 12px 32px;
text-overflow: ellipsis;
width: 100%;
color: variables.$color-black;
@include mixins.dark-theme {
background-color: variables.$color-gray-90;

View file

@ -43,6 +43,7 @@ $input-padding-inline: 12px;
$input-padding-inline + $icon-actual-size + $icon-margin-inline-start
);
@include mixins.font-body-1;
color: light-dark(variables.$color-black, variables.$color-white);
background: light-dark(variables.$color-gray-05, variables.$color-gray-80);
&:focus {

View file

@ -7,6 +7,7 @@
<title>TextSecure test runner</title>
<link rel="stylesheet" href="../node_modules/mocha/mocha.css" />
<link rel="stylesheet" href="../stylesheets/manifest.css" />
<link rel="stylesheet" href="../stylesheets/tailwind.css" />
</head>
<body>
<div id="mocha"></div>

View file

@ -0,0 +1,90 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { Meta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import {
_getAllAxoButtonVariants,
_getAllAxoButtonSizes,
AxoButton,
} from './AxoButton';
export default {
title: 'Axo/AxoButton',
} satisfies Meta;
export function Basic(): JSX.Element {
const variants = _getAllAxoButtonVariants();
const sizes = _getAllAxoButtonSizes();
return (
<div className="grid gap-1">
{sizes.map(size => {
return (
<div>
<h2 className="type-title-medium">Size: {size}</h2>
{variants.map(variant => {
return (
<div key={variant} className="flex gap-1">
<AxoButton
variant={variant}
size={size}
onClick={action('click')}
>
{variant}
</AxoButton>
<AxoButton
variant={variant}
size={size}
onClick={action('click')}
disabled
>
Disabled
</AxoButton>
<AxoButton
symbol="info"
variant={variant}
size={size}
onClick={action('click')}
>
Icon
</AxoButton>
<AxoButton
symbol="info"
variant={variant}
size={size}
onClick={action('click')}
disabled
>
Disabled
</AxoButton>
<AxoButton
arrow
variant={variant}
size={size}
onClick={action('click')}
>
Arrow
</AxoButton>
<AxoButton
arrow
variant={variant}
size={size}
onClick={action('click')}
disabled
>
Disabled
</AxoButton>
</div>
);
})}
</div>
);
})}
</div>
);
}

195
ts/axo/AxoButton.tsx Normal file
View file

@ -0,0 +1,195 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo, forwardRef } from 'react';
import type { ButtonHTMLAttributes, FC, ForwardedRef, ReactNode } from 'react';
import type { Styles } from './_internal/css';
import { css } from './_internal/css';
import { AxoSymbol, type AxoSymbolName } from './AxoSymbol';
import { assert } from './_internal/assert';
const Namespace = 'AxoButton';
const baseAxoButtonStyles = css(
'flex items-center-safe justify-center-safe gap-1 truncate rounded-full select-none',
'outline-0 outline-border-focused focused:outline-[2.5px]'
);
const AxoButtonTypes = {
default: css(baseAxoButtonStyles),
subtle: css(
baseAxoButtonStyles,
'bg-fill-secondary',
'pressed:bg-fill-secondary-pressed'
),
floating: css(
baseAxoButtonStyles,
'bg-fill-floating',
'shadow-elevation-1',
'pressed:bg-fill-floating-pressed'
),
borderless: css(
baseAxoButtonStyles,
'bg-transparent',
'hovered:bg-fill-secondary',
'pressed:bg-fill-secondary-pressed'
),
} as const satisfies Record<string, Styles>;
const AxoButtonVariants = {
// default
secondary: css(
AxoButtonTypes.default,
'bg-fill-secondary text-label-primary',
'pressed:bg-fill-secondary-pressed',
'disabled:text-label-disabled'
),
primary: css(
AxoButtonTypes.default,
'bg-color-fill-primary text-label-primary-on-color',
'pressed:bg-color-fill-primary-pressed',
'disabled:text-label-disabled-on-color'
),
affirmative: css(
AxoButtonTypes.default,
'bg-color-fill-affirmative text-label-primary-on-color',
'pressed:bg-color-fill-affirmative-pressed',
'disabled:text-label-disabled-on-color'
),
destructive: css(
AxoButtonTypes.default,
'bg-color-fill-destructive text-label-primary-on-color',
'pressed:bg-color-fill-destructive-pressed',
'disabled:text-label-disabled-on-color'
),
// subtle
'subtle-primary': css(
AxoButtonTypes.subtle,
'text-color-label-primary',
'disabled:text-color-label-primary-disabled'
),
'subtle-affirmative': css(
AxoButtonTypes.subtle,
'text-color-label-affirmative',
'disabled:text-color-label-affirmative-disabled'
),
'subtle-destructive': css(
AxoButtonTypes.subtle,
'text-color-label-destructive',
'disabled:text-color-label-destructive-disabled'
),
// floating
'floating-secondary': css(
AxoButtonTypes.floating,
'text-label-primary',
'disabled:text-label-disabled'
),
'floating-primary': css(
AxoButtonTypes.floating,
'text-color-label-primary',
'disabled:text-color-label-primary-disabled'
),
'floating-affirmative': css(
AxoButtonTypes.floating,
'text-color-label-affirmative',
'disabled:text-color-label-affirmative-disabled'
),
'floating-destructive': css(
AxoButtonTypes.floating,
'text-color-label-destructive',
'disabled:text-color-label-destructive-disabled'
),
// borderless
'borderless-secondary': css(
AxoButtonTypes.borderless,
'text-label-primary',
'disabled:text-label-disabled'
),
'borderless-primary': css(
AxoButtonTypes.borderless,
'text-color-label-primary',
'disabled:text-color-label-primary-disabled'
),
'borderless-affirmative': css(
AxoButtonTypes.borderless,
'text-color-label-affirmative',
'disabled:text-color-label-affirmative-disabled'
),
'borderless-destructive': css(
AxoButtonTypes.borderless,
'text-color-label-destructive',
'disabled:text-color-label-destructive-disabled'
),
};
const AxoButtonSizes = {
large: css('px-4 py-2 type-body-medium font-medium'),
medium: css('px-3 py-1.5 type-body-medium font-medium'),
small: css('px-2 py-1 type-body-small font-medium'),
} as const satisfies Record<string, Styles>;
type BaseButtonAttrs = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'className' | 'style' | 'children'
>;
type AxoButtonVariant = keyof typeof AxoButtonVariants;
type AxoButtonSize = keyof typeof AxoButtonSizes;
type AxoButtonProps = BaseButtonAttrs &
Readonly<{
variant: AxoButtonVariant;
size: AxoButtonSize;
symbol?: AxoSymbolName;
arrow?: boolean;
children: ReactNode;
}>;
export function _getAllAxoButtonVariants(): ReadonlyArray<AxoButtonVariant> {
return Object.keys(AxoButtonVariants) as Array<AxoButtonVariant>;
}
export function _getAllAxoButtonSizes(): ReadonlyArray<AxoButtonSize> {
return Object.keys(AxoButtonSizes) as Array<AxoButtonSize>;
}
// eslint-disable-next-line import/export
export const AxoButton: FC<AxoButtonProps> = memo(
forwardRef((props, ref: ForwardedRef<HTMLButtonElement>) => {
const { variant, size, symbol, arrow, children, ...rest } = props;
const variantStyles = assert(
AxoButtonVariants[variant],
`${Namespace}: Invalid variant ${variant}`
);
const sizeStyles = assert(
AxoButtonSizes[size],
`${Namespace}: Invalid size ${size}`
);
return (
<button
ref={ref}
type="button"
className={css(variantStyles, sizeStyles)}
{...rest}
>
{symbol != null && (
<AxoSymbol.InlineGlyph symbol={symbol} label={null} />
)}
{children}
{arrow && <AxoSymbol.InlineGlyph symbol="chevron-[end]" label={null} />}
</button>
);
})
);
AxoButton.displayName = `${Namespace}`;
// eslint-disable-next-line max-len
// eslint-disable-next-line @typescript-eslint/no-namespace, @typescript-eslint/no-redeclare, import/export
export namespace AxoButton {
export type Variant = AxoButtonVariant;
export type Size = AxoButtonSize;
export type Props = AxoButtonProps;
}

View file

@ -0,0 +1,99 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import type { Meta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { AxoContextMenu } from './AxoContextMenu';
export default {
title: 'Axo/AxoContextMenu',
} satisfies Meta;
export function Basic(): JSX.Element {
const [showBookmarks, setShowBookmarks] = useState(true);
const [showFullUrls, setShowFullUrls] = useState(false);
const [selectedPerson, setSelectedPerson] = useState('jamie');
return (
<div className="flex h-96 w-full items-center justify-center">
<AxoContextMenu.Root>
<AxoContextMenu.Trigger>
<div className="bg-fill-secondary p-12 text-color-label-primary">
Right-Click
</div>
</AxoContextMenu.Trigger>
<AxoContextMenu.Content>
<AxoContextMenu.Item
symbol="arrow-[start]"
onSelect={action('back')}
keyboardShortcut="⌘["
>
Back
</AxoContextMenu.Item>
<AxoContextMenu.Item
disabled
symbol="arrow-[end]"
onSelect={action('forward')}
keyboardShortcut="⌘]"
>
Forward
</AxoContextMenu.Item>
<AxoContextMenu.Item
onSelect={action('reload')}
keyboardShortcut="⌘R"
>
Reload
</AxoContextMenu.Item>
<AxoContextMenu.Sub>
<AxoContextMenu.SubTrigger>More Tools</AxoContextMenu.SubTrigger>
<AxoContextMenu.SubContent>
<AxoContextMenu.Item
onSelect={action('savePageAs')}
keyboardShortcut="⌘S"
>
Save Page As...
</AxoContextMenu.Item>
<AxoContextMenu.Item onSelect={action('createShortcut')}>
Create Shortcut...
</AxoContextMenu.Item>
<AxoContextMenu.Item onSelect={action('nameWindow')}>
Name Window...
</AxoContextMenu.Item>
<AxoContextMenu.Separator />
<AxoContextMenu.Item onSelect={action('developerTools')}>
Developer Tools
</AxoContextMenu.Item>
</AxoContextMenu.SubContent>
</AxoContextMenu.Sub>
<AxoContextMenu.Separator />
<AxoContextMenu.CheckboxItem
checked={showBookmarks}
onCheckedChange={setShowBookmarks}
keyboardShortcut="⌘B"
>
Show Bookmarks
</AxoContextMenu.CheckboxItem>
<AxoContextMenu.CheckboxItem
symbol="link"
checked={showFullUrls}
onCheckedChange={setShowFullUrls}
>
Show Full URLs
</AxoContextMenu.CheckboxItem>
<AxoContextMenu.Separator />
<AxoContextMenu.Label>People</AxoContextMenu.Label>
<AxoContextMenu.RadioGroup
value={selectedPerson}
onValueChange={setSelectedPerson}
>
<AxoContextMenu.RadioItem value="jamie">
Jamie
</AxoContextMenu.RadioItem>
<AxoContextMenu.RadioItem value="tyler">
Tyler
</AxoContextMenu.RadioItem>
</AxoContextMenu.RadioGroup>
</AxoContextMenu.Content>
</AxoContextMenu.Root>
</div>
);
}

388
ts/axo/AxoContextMenu.tsx Normal file
View file

@ -0,0 +1,388 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo } from 'react';
import { ContextMenu } from 'radix-ui';
import type { FC } from 'react';
import { AxoSymbol } from './AxoSymbol';
import { AxoBaseMenu } from './_internal/AxoBaseMenu';
const Namespace = 'AxoContextMenu';
/**
* Displays a menu located at the pointer, triggered by a right click or a long press.
*
* Note: For menus that are triggered by a normal button press, you should use
* `AxoDropdownMenu`.
*
* @example Anatomy
* ```tsx
* import { AxoContextMenu } from "./axo/ContextMenu/AxoContentMenu.tsx";
*
* export default () => (
* <AxoContextMenu.Root>
* <AxoContextMenu.Trigger />
*
* <AxoContextMenu.Content>
* <AxoContextMenu.Label />
* <AxoContextMenu.Item />
*
* <AxoContextMenu.Group>
* <AxoContextMenu.Item />
* </AxoContextMenu.Group>
*
* <AxoContextMenu.CheckboxItem/>
*
* <AxoContextMenu.RadioGroup>
* <AxoContextMenu.RadioItem/>
* </AxoContextMenu.RadioGroup>
*
* <AxoContextMenu.Sub>
* <AxoContextMenu.SubTrigger />
* <AxoContextMenu.SubContent />
* </AxoContextMenu.Sub>
*
* <AxoContextMenu.Separator />
* </AxoContextMenu.Content>
* </AxoContextMenu.Root>
* )
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace AxoContextMenu {
/**
* Component: <AxoContextMenu.Root>
* --------------------------------
*/
export type RootProps = AxoBaseMenu.MenuRootProps;
export const Root: FC<RootProps> = memo(props => {
return <ContextMenu.Root>{props.children}</ContextMenu.Root>;
});
Root.displayName = `${Namespace}.Root`;
/**
* Component: <AxoContextMenu.Trigger>
* -----------------------------------
*/
export type TriggerProps = AxoBaseMenu.MenuTriggerProps;
export const Trigger: FC<TriggerProps> = memo(props => {
return <ContextMenu.Trigger>{props.children}</ContextMenu.Trigger>;
});
Trigger.displayName = `${Namespace}.Trigger`;
/**
* Component: <AxoContextMenu.Content>
* -----------------------------------
*/
export type ContentProps = AxoBaseMenu.MenuContentProps;
/**
* The component that pops out in an open context menu.
* Uses a portal to render the content part into the `body`.
*/
export const Content: FC<ContentProps> = memo(props => {
return (
<ContextMenu.Portal>
<ContextMenu.Content
className={AxoBaseMenu.menuContentStyles}
alignOffset={-6}
collisionPadding={6}
>
{props.children}
</ContextMenu.Content>
</ContextMenu.Portal>
);
});
Content.displayName = `${Namespace}.Content`;
/**
* Component: <AxoContextMenu.Item>
* --------------------------------
*/
export type ItemProps = AxoBaseMenu.MenuItemProps;
/**
* The component that contains the context menu items.
* @example
* ```tsx
* <AxoContextMenu.Item icon={<svg/>}>
* {i18n("myContextMenuText")}
* </AxoContentMenu.Item>
* ````
*/
export const Item: FC<ItemProps> = memo(props => {
return (
<ContextMenu.Item
disabled={props.disabled}
textValue={props.textValue}
onSelect={props.onSelect}
className={AxoBaseMenu.menuItemStyles}
>
{props.symbol && (
<AxoBaseMenu.ItemLeadingSlot>
<AxoBaseMenu.ItemSymbol symbol={props.symbol} />
</AxoBaseMenu.ItemLeadingSlot>
)}
<AxoBaseMenu.ItemContentSlot>
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
{props.keyboardShortcut && (
<AxoBaseMenu.ItemKeyboardShortcut
keyboardShortcut={props.keyboardShortcut}
/>
)}
</AxoBaseMenu.ItemContentSlot>
</ContextMenu.Item>
);
});
Item.displayName = `${Namespace}.Item`;
/**
* Component: <AxoContextMenu.Group>
* ---------------------------------
*/
export type GroupProps = AxoBaseMenu.MenuGroupProps;
/**
* Used to group multiple {@link AxoContextMenu.Item}'s.
*/
export const Group: FC<GroupProps> = memo(props => {
return (
<ContextMenu.Group className={AxoBaseMenu.menuGroupStyles}>
{props.children}
</ContextMenu.Group>
);
});
Group.displayName = `${Namespace}.Group`;
/**
* Component: <AxoContextMenu.Label>
* ---------------------------------
*/
export type LabelProps = AxoBaseMenu.MenuLabelProps;
/**
* Used to render a label. It won't be focusable using arrow keys.
*/
export const Label: FC<LabelProps> = memo(props => {
return (
<ContextMenu.Label className={AxoBaseMenu.menuLabelStyles}>
<AxoBaseMenu.ItemContentSlot>
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
</AxoBaseMenu.ItemContentSlot>
</ContextMenu.Label>
);
});
Label.displayName = `${Namespace}.Label`;
/**
* Component: <AxoContextMenu.CheckboxItem>
* ----------------------------------------
*/
export type CheckboxItemProps = AxoBaseMenu.MenuCheckboxItemProps;
/**
* An item that can be controlled and rendered like a checkbox.
*/
export const CheckboxItem: FC<CheckboxItemProps> = memo(props => {
return (
<ContextMenu.CheckboxItem
textValue={props.textValue}
disabled={props.disabled}
checked={props.checked}
onCheckedChange={props.onCheckedChange}
onSelect={props.onSelect}
className={AxoBaseMenu.menuCheckboxItemStyles}
>
<AxoBaseMenu.ItemLeadingSlot>
<AxoBaseMenu.ItemCheckPlaceholder>
<ContextMenu.ItemIndicator>
<AxoBaseMenu.ItemCheck />
</ContextMenu.ItemIndicator>
</AxoBaseMenu.ItemCheckPlaceholder>
</AxoBaseMenu.ItemLeadingSlot>
<AxoBaseMenu.ItemContentSlot>
{props.symbol && (
<span className="mr-2">
<AxoBaseMenu.ItemSymbol symbol={props.symbol} />
</span>
)}
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
{props.keyboardShortcut && (
<AxoBaseMenu.ItemKeyboardShortcut
keyboardShortcut={props.keyboardShortcut}
/>
)}
</AxoBaseMenu.ItemContentSlot>
</ContextMenu.CheckboxItem>
);
});
CheckboxItem.displayName = `${Namespace}.CheckboxItem`;
/**
* Component: <AxoContextMenu.RadioGroup>
* --------------------------------------
*/
export type RadioGroupProps = AxoBaseMenu.MenuRadioGroupProps;
/**
* Used to group multiple {@link AxoContextMenu.RadioItem}'s.
*/
export const RadioGroup: FC<RadioGroupProps> = memo(props => {
return (
<ContextMenu.RadioGroup
value={props.value}
onValueChange={props.onValueChange}
className={AxoBaseMenu.menuRadioGroupStyles}
>
{props.children}
</ContextMenu.RadioGroup>
);
});
RadioGroup.displayName = `${Namespace}.RadioGroup`;
/**
* Component: <AxoContextMenu.RadioItem>
* -------------------------------------
*/
export type RadioItemProps = AxoBaseMenu.MenuRadioItemProps;
/**
* An item that can be controlled and rendered like a radio.
*/
export const RadioItem: FC<RadioItemProps> = memo(props => {
return (
<ContextMenu.RadioItem
value={props.value}
className={AxoBaseMenu.menuRadioItemStyles}
onSelect={props.onSelect}
>
<AxoBaseMenu.ItemLeadingSlot>
<AxoBaseMenu.ItemCheckPlaceholder>
<ContextMenu.ItemIndicator>
<AxoBaseMenu.ItemCheck />
</ContextMenu.ItemIndicator>
</AxoBaseMenu.ItemCheckPlaceholder>
</AxoBaseMenu.ItemLeadingSlot>
<AxoBaseMenu.ItemContentSlot>
{props.symbol && <AxoBaseMenu.ItemSymbol symbol={props.symbol} />}
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
{props.keyboardShortcut && (
<AxoBaseMenu.ItemKeyboardShortcut
keyboardShortcut={props.keyboardShortcut}
/>
)}
</AxoBaseMenu.ItemContentSlot>
</ContextMenu.RadioItem>
);
});
RadioItem.displayName = `${Namespace}.RadioItem`;
/**
* Component: <AxoContextMenu.Separator>
* -------------------------------------
*/
export type SeparatorProps = AxoBaseMenu.MenuSeparatorProps;
/**
* Used to visually separate items in the context menu.
*/
export const Separator: FC<SeparatorProps> = memo(() => {
return (
<ContextMenu.Separator className={AxoBaseMenu.menuSeparatorStyles} />
);
});
Separator.displayName = `${Namespace}.Separator`;
/**
* Component: <AxoContextMenu.Sub>
* -------------------------------
*/
export type SubProps = AxoBaseMenu.MenuSubProps;
/**
* Contains all the parts of a submenu.
*/
export const Sub: FC<SubProps> = memo(props => {
return <ContextMenu.Sub>{props.children}</ContextMenu.Sub>;
});
Sub.displayName = `${Namespace}.Sub`;
/**
* Component: <AxoContextMenu.SubTrigger>
* --------------------------------------
*/
export type SubTriggerProps = AxoBaseMenu.MenuSubTriggerProps;
/**
* An item that opens a submenu. Must be rendered inside
* {@link ContextMenu.Sub}.
*/
export const SubTrigger: FC<SubTriggerProps> = memo(props => {
return (
<ContextMenu.SubTrigger className={AxoBaseMenu.menuSubTriggerStyles}>
{props.symbol && (
<AxoBaseMenu.ItemLeadingSlot>
<AxoBaseMenu.ItemSymbol symbol={props.symbol} />
</AxoBaseMenu.ItemLeadingSlot>
)}
<AxoBaseMenu.ItemContentSlot>
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
<span className="ml-auto">
<AxoSymbol.Icon size={14} symbol="chevron-[end]" label={null} />
</span>
</AxoBaseMenu.ItemContentSlot>
</ContextMenu.SubTrigger>
);
});
SubTrigger.displayName = `${Namespace}.SubTrigger`;
/**
* Component: <AxoContextMenu.SubContent>
* --------------------------------------
*/
export type SubContentProps = AxoBaseMenu.MenuSubContentProps;
/**
* The component that pops out when a submenu is open. Must be rendered
* inside {@link AxoContextMenu.Sub}.
*/
export const SubContent: FC<SubContentProps> = memo(props => {
return (
<ContextMenu.SubContent
alignOffset={-6}
collisionPadding={6}
className={AxoBaseMenu.menuSubContentStyles}
>
{props.children}
</ContextMenu.SubContent>
);
});
SubContent.displayName = `${Namespace}.SubContent`;
}

View file

@ -0,0 +1,100 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import type { Meta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { AxoDropdownMenu } from './AxoDropdownMenu';
import { AxoButton } from './AxoButton';
export default {
title: 'Axo/AxoDropdownMenu',
} satisfies Meta;
export function Basic(): JSX.Element {
const [showBookmarks, setShowBookmarks] = useState(true);
const [showFullUrls, setShowFullUrls] = useState(false);
const [selectedPerson, setSelectedPerson] = useState('jamie');
return (
<div className="flex h-96 w-full items-center justify-center">
<AxoDropdownMenu.Root>
<AxoDropdownMenu.Trigger>
<AxoButton variant="secondary" size="medium">
Open Dropdown Menu
</AxoButton>
</AxoDropdownMenu.Trigger>
<AxoDropdownMenu.Content>
<AxoDropdownMenu.Item
symbol="arrow-[start]"
onSelect={action('back')}
keyboardShortcut="⌘["
>
Back
</AxoDropdownMenu.Item>
<AxoDropdownMenu.Item
disabled
symbol="arrow-[end]"
onSelect={action('forward')}
keyboardShortcut="⌘]"
>
Forward
</AxoDropdownMenu.Item>
<AxoDropdownMenu.Item
onSelect={action('reload')}
keyboardShortcut="⌘R"
>
Reload
</AxoDropdownMenu.Item>
<AxoDropdownMenu.Sub>
<AxoDropdownMenu.SubTrigger>More Tools</AxoDropdownMenu.SubTrigger>
<AxoDropdownMenu.SubContent>
<AxoDropdownMenu.Item
onSelect={action('savePageAs')}
keyboardShortcut="⌘S"
>
Save Page As...
</AxoDropdownMenu.Item>
<AxoDropdownMenu.Item onSelect={action('createShortcut')}>
Create Shortcut...
</AxoDropdownMenu.Item>
<AxoDropdownMenu.Item onSelect={action('nameWindow')}>
Name Window...
</AxoDropdownMenu.Item>
<AxoDropdownMenu.Separator />
<AxoDropdownMenu.Item onSelect={action('developerTools')}>
Developer Tools
</AxoDropdownMenu.Item>
</AxoDropdownMenu.SubContent>
</AxoDropdownMenu.Sub>
<AxoDropdownMenu.Separator />
<AxoDropdownMenu.CheckboxItem
checked={showBookmarks}
onCheckedChange={setShowBookmarks}
keyboardShortcut="⌘B"
>
Show Bookmarks
</AxoDropdownMenu.CheckboxItem>
<AxoDropdownMenu.CheckboxItem
symbol="link"
checked={showFullUrls}
onCheckedChange={setShowFullUrls}
>
Show Full URLs
</AxoDropdownMenu.CheckboxItem>
<AxoDropdownMenu.Separator />
<AxoDropdownMenu.Label>People</AxoDropdownMenu.Label>
<AxoDropdownMenu.RadioGroup
value={selectedPerson}
onValueChange={setSelectedPerson}
>
<AxoDropdownMenu.RadioItem value="jamie">
Jamie
</AxoDropdownMenu.RadioItem>
<AxoDropdownMenu.RadioItem value="tyler">
Tyler
</AxoDropdownMenu.RadioItem>
</AxoDropdownMenu.RadioGroup>
</AxoDropdownMenu.Content>
</AxoDropdownMenu.Root>
</div>
);
}

402
ts/axo/AxoDropdownMenu.tsx Normal file
View file

@ -0,0 +1,402 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo } from 'react';
import { DropdownMenu } from 'radix-ui';
import type { FC } from 'react';
import { AxoSymbol } from './AxoSymbol';
import { AxoBaseMenu } from './_internal/AxoBaseMenu';
const Namespace = 'AxoDropdownMenu';
/**
* Displays a menu to the usersuch as a set of actions or functionstriggered
* by a button.
*
* Note: For menus that are triggered by a right-click, you should use
* `AxoContextMenu`.
*
* @example Anatomy
* ```tsx
* import { AxoDropdownMenu } from "./axo/DropdownMenu/AxoDropdownMenu.tsx";
*
* export default () => (
* <AxoDropdownMenu.Root>
* <AxoDropdownMenu.Trigger>
* <button>Click Me</button>
* </AxoDropdownMenu.Trigger>
*
* <AxoDropdownMenu.Content>
* <AxoDropdownMenu.Label />
* <AxoDropdownMenu.Item />
*
* <AxoDropdownMenu.Group>
* <AxoDropdownMenu.Item />
* </AxoDropdownMenu.Group>
*
* <AxoDropdownMenu.CheckboxItem/>
*
* <AxoDropdownMenu.RadioGroup>
* <AxoDropdownMenu.RadioItem/>
* </AxoDropdownMenu.RadioGroup>
*
* <AxoDropdownMenu.Sub>
* <AxoDropdownMenu.SubTrigger />
* <AxoDropdownMenu.SubContent />
* </AxoDropdownMenu.Sub>
*
* <AxoDropdownMenu.Separator />
* </AxoDropdownMenu.Content>
* </AxoDropdownMenu.Root>
* )
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace AxoDropdownMenu {
/**
* Component: <AxoDropdownMenu.Root>
* ---------------------------------
*/
export type RootProps = AxoBaseMenu.MenuRootProps;
/**
* Contains all the parts of a dropdown menu.
*/
export const Root: FC<RootProps> = memo(props => {
return <DropdownMenu.Root>{props.children}</DropdownMenu.Root>;
});
Root.displayName = `${Namespace}.Root`;
/**
* Component: <AxoDropdownMenu.Trigger>
* ------------------------------------
*/
export type TriggerProps = AxoBaseMenu.MenuTriggerProps;
/**
* The button that toggles the dropdown menu.
* By default, the {@link AxoDropdownMenu.Content} will position itself
* against the trigger.
*/
export const Trigger: FC<TriggerProps> = memo(props => {
return (
<DropdownMenu.Trigger asChild>{props.children}</DropdownMenu.Trigger>
);
});
Trigger.displayName = `${Namespace}.Trigger`;
/**
* Component: <AxoDropdownMenu.Content>
* ------------------------------------
*/
export type ContentProps = AxoBaseMenu.MenuContentProps;
/**
* The component that pops out when the dropdown menu is open.
* Uses a portal to render the content part into the `body`.
*/
export const Content: FC<ContentProps> = memo(props => {
return (
<DropdownMenu.Portal>
<DropdownMenu.Content
sideOffset={4}
align="start"
collisionPadding={6}
className={AxoBaseMenu.menuContentStyles}
>
{props.children}
</DropdownMenu.Content>
</DropdownMenu.Portal>
);
});
Content.displayName = `${Namespace}.Content`;
/**
* Component: <AxoDropdownMenu.Item>
* ---------------------------------
*/
export type ItemProps = AxoBaseMenu.MenuItemProps;
/**
* The component that contains the dropdown menu items.
* @example
* ```tsx
* <AxoDropdownMenu.Item icon={<svg/>}>
* {i18n("myContextMenuText")}
* </AxoContentMenu.Item>
* ````
*/
export const Item: FC<ItemProps> = memo(props => {
return (
<DropdownMenu.Item
disabled={props.disabled}
textValue={props.textValue}
onSelect={props.onSelect}
className={AxoBaseMenu.menuItemStyles}
>
{props.symbol && (
<AxoBaseMenu.ItemLeadingSlot>
<AxoBaseMenu.ItemSymbol symbol={props.symbol} />
</AxoBaseMenu.ItemLeadingSlot>
)}
<AxoBaseMenu.ItemContentSlot>
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
{props.keyboardShortcut && (
<AxoBaseMenu.ItemKeyboardShortcut
keyboardShortcut={props.keyboardShortcut}
/>
)}
</AxoBaseMenu.ItemContentSlot>
</DropdownMenu.Item>
);
});
Item.displayName = `${Namespace}.Item`;
/**
* Component: <AxoDropdownMenu.Group>
* ----------------------------------
*/
export type GroupProps = AxoBaseMenu.MenuGroupProps;
/**
* Used to group multiple {@link AxoDropdownMenu.Item}'s.
*/
export const Group: FC<GroupProps> = memo(props => {
return (
<DropdownMenu.Group className={AxoBaseMenu.menuGroupStyles}>
{props.children}
</DropdownMenu.Group>
);
});
Group.displayName = `${Namespace}.Group`;
/**
* Component: <AxoDropdownMenu.Label>
* ----------------------------------
*/
export type LabelProps = AxoBaseMenu.MenuLabelProps;
/**
* Used to render a label. It won't be focusable using arrow keys.
*/
export const Label: FC<LabelProps> = memo(props => {
return (
<DropdownMenu.Label className={AxoBaseMenu.menuLabelStyles}>
<AxoBaseMenu.ItemContentSlot>
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
</AxoBaseMenu.ItemContentSlot>
</DropdownMenu.Label>
);
});
Label.displayName = `${Namespace}.Label`;
/**
* Component: <AxoDropdownMenu.CheckboxItem>
* -----------------------------------------
*/
export type CheckboxItemProps = AxoBaseMenu.MenuCheckboxItemProps;
/**
* An item that can be controlled and rendered like a checkbox.
*/
export const CheckboxItem: FC<CheckboxItemProps> = memo(props => {
return (
<DropdownMenu.CheckboxItem
textValue={props.textValue}
disabled={props.disabled}
checked={props.checked}
onCheckedChange={props.onCheckedChange}
onSelect={props.onSelect}
className={AxoBaseMenu.menuCheckboxItemStyles}
>
<AxoBaseMenu.ItemLeadingSlot>
<AxoBaseMenu.ItemCheckPlaceholder>
<DropdownMenu.ItemIndicator>
<AxoBaseMenu.ItemCheck />
</DropdownMenu.ItemIndicator>
</AxoBaseMenu.ItemCheckPlaceholder>
</AxoBaseMenu.ItemLeadingSlot>
<AxoBaseMenu.ItemContentSlot>
{props.symbol && (
<span className="mr-2">
<AxoBaseMenu.ItemSymbol symbol={props.symbol} />
</span>
)}
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
{props.keyboardShortcut && (
<AxoBaseMenu.ItemKeyboardShortcut
keyboardShortcut={props.keyboardShortcut}
/>
)}
</AxoBaseMenu.ItemContentSlot>
</DropdownMenu.CheckboxItem>
);
});
CheckboxItem.displayName = `${Namespace}.CheckboxItem`;
/**
* Component: <AxoDropdownMenu.RadioGroup>
* ---------------------------------------
*/
export type RadioGroupProps = AxoBaseMenu.MenuRadioGroupProps;
/**
* Used to group multiple {@link AxoDropdownMenu.RadioItem}'s.
*/
export const RadioGroup: FC<RadioGroupProps> = memo(props => {
return (
<DropdownMenu.RadioGroup
value={props.value}
onValueChange={props.onValueChange}
className={AxoBaseMenu.menuRadioGroupStyles}
>
{props.children}
</DropdownMenu.RadioGroup>
);
});
RadioGroup.displayName = `${Namespace}.RadioGroup`;
/**
* Component: <AxoDropdownMenu.RadioItem>
* --------------------------------------
*/
export type RadioItemProps = AxoBaseMenu.MenuRadioItemProps;
/**
* An item that can be controlled and rendered like a radio.
*/
export const RadioItem: FC<RadioItemProps> = memo(props => {
return (
<DropdownMenu.RadioItem
value={props.value}
className={AxoBaseMenu.menuRadioItemStyles}
onSelect={props.onSelect}
>
<AxoBaseMenu.ItemLeadingSlot>
<AxoBaseMenu.ItemCheckPlaceholder>
<DropdownMenu.ItemIndicator>
<AxoBaseMenu.ItemCheck />
</DropdownMenu.ItemIndicator>
</AxoBaseMenu.ItemCheckPlaceholder>
</AxoBaseMenu.ItemLeadingSlot>
<AxoBaseMenu.ItemContentSlot>
{props.symbol && <AxoBaseMenu.ItemSymbol symbol={props.symbol} />}
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
{props.keyboardShortcut && (
<AxoBaseMenu.ItemKeyboardShortcut
keyboardShortcut={props.keyboardShortcut}
/>
)}
</AxoBaseMenu.ItemContentSlot>
</DropdownMenu.RadioItem>
);
});
RadioItem.displayName = `${Namespace}.RadioItem`;
/**
* Component: <AxoDropdownMenu.Separator>
* --------------------------------------
*/
export type SeparatorProps = AxoBaseMenu.MenuSeparatorProps;
/**
* Used to visually separate items in the dropdown menu.
*/
export const Separator: FC<SeparatorProps> = memo(() => {
return (
<DropdownMenu.Separator className={AxoBaseMenu.menuSeparatorStyles} />
);
});
Separator.displayName = `${Namespace}.Separator`;
/**
* Component: <AxoDropdownMenu.Sub>
* -------------------------------
*/
export type SubProps = AxoBaseMenu.MenuSubProps;
/**
* Contains all the parts of a submenu.
*/
export const Sub: FC<SubProps> = memo(props => {
return <DropdownMenu.Sub>{props.children}</DropdownMenu.Sub>;
});
Sub.displayName = `${Namespace}.Sub`;
/**
* Component: <AxoDropdownMenu.SubTrigger>
* ---------------------------------------
*/
export type SubTriggerProps = AxoBaseMenu.MenuSubTriggerProps;
/**
* An item that opens a submenu. Must be rendered inside
* {@link ContextMenu.Sub}.
*/
export const SubTrigger: FC<SubTriggerProps> = memo(props => {
return (
<DropdownMenu.SubTrigger className={AxoBaseMenu.menuSubTriggerStyles}>
{props.symbol && (
<AxoBaseMenu.ItemLeadingSlot>
<AxoBaseMenu.ItemSymbol symbol={props.symbol} />
</AxoBaseMenu.ItemLeadingSlot>
)}
<AxoBaseMenu.ItemContentSlot>
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
<span className="ml-auto">
<AxoSymbol.Icon size={14} symbol="chevron-[end]" label={null} />
</span>
</AxoBaseMenu.ItemContentSlot>
</DropdownMenu.SubTrigger>
);
});
SubTrigger.displayName = `${Namespace}.SubTrigger`;
/**
* Component: <AxoDropdownMenu.SubContent>
* ---------------------------------------
*/
export type SubContentProps = AxoBaseMenu.MenuSubContentProps;
/**
* The component that pops out when a submenu is open. Must be rendered
* inside {@link AxoDropdownMenu.Sub}.
*/
export const SubContent: FC<SubContentProps> = memo(props => {
return (
<DropdownMenu.SubContent
alignOffset={-6}
collisionPadding={6}
className={AxoBaseMenu.menuSubContentStyles}
>
{props.children}
</DropdownMenu.SubContent>
);
});
SubContent.displayName = `${Namespace}.SubContent`;
}

View file

@ -0,0 +1,90 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import type { Meta } from '@storybook/react';
import { AxoSelect } from './AxoSelect';
export default {
title: 'Axo/AxoSelect',
} satisfies Meta;
function Template(props: {
disabled?: boolean;
triggerWidth?: AxoSelect.TriggerWidth;
triggerVariant: AxoSelect.TriggerVariant;
}) {
const [value, setValue] = useState<string | null>(null);
return (
<AxoSelect.Root
value={value}
onValueChange={setValue}
disabled={props.disabled}
>
<AxoSelect.Trigger
variant={props.triggerVariant}
width={props.triggerWidth}
placeholder="Select an item..."
/>
<AxoSelect.Content>
<AxoSelect.Group>
<AxoSelect.Label>Fruits</AxoSelect.Label>
<AxoSelect.Item value="apple">Apple</AxoSelect.Item>
<AxoSelect.Item value="banana">Banana</AxoSelect.Item>
<AxoSelect.Item value="blueberry">Blueberry</AxoSelect.Item>
<AxoSelect.Item value="grapes">Grapes</AxoSelect.Item>
<AxoSelect.Item value="pineapple">Pineapple</AxoSelect.Item>
</AxoSelect.Group>
<AxoSelect.Separator />
<AxoSelect.Group>
<AxoSelect.Label>Vegetables</AxoSelect.Label>
<AxoSelect.Item value="aubergine">Aubergine</AxoSelect.Item>
<AxoSelect.Item value="broccoli">Broccoli</AxoSelect.Item>
<AxoSelect.Item value="carrot" disabled>
Carrot
</AxoSelect.Item>
<AxoSelect.Item value="leek">Leek</AxoSelect.Item>
</AxoSelect.Group>
<AxoSelect.Separator />
<AxoSelect.Group>
<AxoSelect.Label>Meat</AxoSelect.Label>
<AxoSelect.Item value="beef">Beef</AxoSelect.Item>
<AxoSelect.Item value="chicken">Chicken</AxoSelect.Item>
<AxoSelect.Item value="lamb">Lamb</AxoSelect.Item>
<AxoSelect.Item value="pork">Pork</AxoSelect.Item>
</AxoSelect.Group>
</AxoSelect.Content>
</AxoSelect.Root>
);
}
export function Basic(): JSX.Element {
return (
<div className="flex h-96 w-full flex-col items-center justify-center gap-2">
<div className="flex gap-2">
<Template triggerVariant="default" />
<Template triggerVariant="default" disabled />
</div>
<div className="flex gap-2">
<Template triggerVariant="floating" />
<Template triggerVariant="floating" disabled />
</div>
<div className="flex gap-2">
<Template triggerVariant="borderless" />
<Template triggerVariant="borderless" disabled />
</div>
<div className="flex gap-2">
<Template triggerWidth="fixed" triggerVariant="default" />
<Template triggerWidth="fixed" triggerVariant="default" disabled />
</div>
<div className="flex gap-2">
<Template triggerWidth="fixed" triggerVariant="floating" />
<Template triggerWidth="fixed" triggerVariant="floating" disabled />
</div>
<div className="flex gap-2">
<Template triggerWidth="fixed" triggerVariant="borderless" />
<Template triggerWidth="fixed" triggerVariant="borderless" disabled />
</div>
</div>
);
}

288
ts/axo/AxoSelect.tsx Normal file
View file

@ -0,0 +1,288 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo } from 'react';
import type { FC, ReactNode } from 'react';
import { Select } from 'radix-ui';
import { AxoBaseMenu } from './_internal/AxoBaseMenu';
import { AxoSymbol } from './AxoSymbol';
import type { Styles } from './_internal/css';
import { css } from './_internal/css';
const Namespace = 'AxoSelect';
/**
* Displays a list of options for the user to pick fromtriggered by a button.
*
* @example Anatomy
* ```tsx
* export default () => (
* <AxoSelect.Root>
* <AxoSelect.Trigger/>
* <AxoSelect.Content>
* <AxoSelect.Item/>
* <AxoSelect.Separator/>
* <AxoSelect.Group>
* <AxoSelect.Label/>
* <AxoSelect.Item/>
* </AxoSelect.Group>
* </AxoSelect.Content>
* </Select.Root>
* );
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace AxoSelect {
/**
* Component: <AxoSelect.Root>
* ---------------------------
*/
export type RootProps = Readonly<{
name?: string;
form?: string;
autoComplete?: string;
disabled?: boolean;
required?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
value: string | null;
onValueChange: (value: string) => void;
children: ReactNode;
}>;
/**
* Contains all the parts of a select.
*/
export const Root: FC<RootProps> = memo(props => {
return (
<Select.Root
name={props.name}
form={props.form}
autoComplete={props.autoComplete}
disabled={props.disabled}
required={props.required}
open={props.open}
onOpenChange={props.onOpenChange}
value={props.value ?? undefined}
onValueChange={props.onValueChange}
>
{props.children}
</Select.Root>
);
});
Root.displayName = `${Namespace}.Root`;
/**
* Component: <AxoSelect.Trigger>
* ---------------------------
*/
const baseTriggerStyles = css(
'flex',
'rounded-full py-[5px] ps-3 pe-2.5 text-label-primary',
'disabled:text-label-disabled',
'outline-0 outline-border-focused focused:outline-[2.5px]'
);
const TriggerVariants = {
default: css(
baseTriggerStyles,
'bg-fill-secondary',
'pressed:bg-fill-secondary-pressed'
),
floating: css(
baseTriggerStyles,
'bg-fill-floating',
'shadow-elevation-1',
'pressed:bg-fill-floating-pressed'
),
borderless: css(
baseTriggerStyles,
'bg-transparent',
'hovered:bg-fill-secondary',
'pressed:bg-fill-secondary-pressed'
),
} as const satisfies Record<string, Styles>;
const TriggerWidths = {
hug: css(),
fixed: css('w-[120px]'),
};
export type TriggerVariant = keyof typeof TriggerVariants;
export type TriggerWidth = keyof typeof TriggerWidths;
export type TriggerProps = Readonly<{
variant?: TriggerVariant;
width?: TriggerWidth;
placeholder: string;
children?: ReactNode;
}>;
/**
* The button that toggles the select.
* The {@link AxoSelect.Content} will position itself by aligning over the
* trigger.
*/
export const Trigger: FC<TriggerProps> = memo(props => {
const variant = props.variant ?? 'default';
const width = props.width ?? 'hug';
const variantStyles = TriggerVariants[variant];
const widthStyles = TriggerWidths[width];
return (
<Select.Trigger className={css(variantStyles, widthStyles)}>
<AxoBaseMenu.ItemText>
<Select.Value placeholder={props.placeholder}>
{props.children}
</Select.Value>
</AxoBaseMenu.ItemText>
<Select.Icon className="ml-2">
<AxoSymbol.Icon symbol="chevron-down" size={14} label={null} />
</Select.Icon>
</Select.Trigger>
);
});
Trigger.displayName = `${Namespace}.Trigger`;
/**
* Component: <AxoSelect.Content>
* ------------------------------
*/
export type ContentProps = Readonly<{
children: ReactNode;
}>;
/**
* The component that pops out when the select is open.
* Uses a portal to render the content part into the `body`.
*/
export const Content: FC<ContentProps> = memo(props => {
return (
<Select.Portal>
<Select.Content className={AxoBaseMenu.selectContentStyles}>
<Select.ScrollUpButton className="flex items-center justify-center p-1 text-label-primary">
<AxoSymbol.Icon symbol="chevron-up" size={14} label={null} />
</Select.ScrollUpButton>
<Select.Viewport className={AxoBaseMenu.selectContentViewportStyles}>
{props.children}
</Select.Viewport>
<Select.ScrollDownButton className="flex items-center justify-center p-1 text-label-primary">
<AxoSymbol.Icon symbol="chevron-down" size={14} label={null} />
</Select.ScrollDownButton>
</Select.Content>
</Select.Portal>
);
});
Content.displayName = `${Namespace}.Content`;
/**
* Component: <AxoSelect.Item>
* ---------------------------
*/
export type ItemProps = Readonly<{
value: string;
disabled?: boolean;
textValue?: string;
children: ReactNode;
}>;
/**
* The component that contains the select items.
*/
export const Item: FC<ItemProps> = memo(props => {
return (
<Select.Item
value={props.value}
disabled={props.disabled}
textValue={props.textValue}
className={AxoBaseMenu.selectItemStyles}
>
<AxoBaseMenu.ItemLeadingSlot>
<AxoBaseMenu.ItemCheckPlaceholder>
<Select.ItemIndicator>
<AxoBaseMenu.ItemCheck />
</Select.ItemIndicator>
</AxoBaseMenu.ItemCheckPlaceholder>
</AxoBaseMenu.ItemLeadingSlot>
<AxoBaseMenu.ItemContentSlot>
<AxoBaseMenu.ItemText>
<Select.ItemText>{props.children}</Select.ItemText>
</AxoBaseMenu.ItemText>
</AxoBaseMenu.ItemContentSlot>
</Select.Item>
);
});
Item.displayName = `${Namespace}.Content`;
/**
* Component: <AxoSelect.Group>
* ---------------------------
*/
export type GroupProps = Readonly<{
children: ReactNode;
}>;
/**
* Used to group multiple items.
* Use in conjunction with {@link AxoSelect.Label to ensure good accessibility
* via automatic labelling.
*/
export const Group: FC<GroupProps> = memo(props => {
return (
<Select.Group className={AxoBaseMenu.selectGroupStyles}>
{props.children}
</Select.Group>
);
});
Group.displayName = `${Namespace}.Group`;
/**
* Component: <AxoSelect.Label>
* ---------------------------
*/
export type LabelProps = Readonly<{
children: ReactNode;
}>;
/**
* Used to render the label of a group. It won't be focusable using arrow keys.
*/
export const Label: FC<LabelProps> = memo(props => {
return (
<Select.Label className={AxoBaseMenu.selectLabelStyles}>
<AxoBaseMenu.ItemContentSlot>
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
</AxoBaseMenu.ItemContentSlot>
</Select.Label>
);
});
Label.displayName = `${Namespace}.Label`;
/**
* Component: <AxoSelect.Separator>
* ---------------------------
*/
export type SeparatorProps = Readonly<{
// N/A
}>;
/**
* Used to visually separate items in the select.
*/
export const Separator: FC<SeparatorProps> = memo(() => {
return <Select.Separator className={AxoBaseMenu.selectSeperatorStyles} />;
});
Separator.displayName = `${Namespace}.Separator`;
}

View file

@ -0,0 +1,102 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo, useMemo, useState } from 'react';
import type { Meta } from '@storybook/react';
import { Direction } from 'radix-ui';
import Fuse from 'fuse.js';
import type { AxoSymbolName } from './AxoSymbol';
import { AxoSymbol, _getAllAxoSymbolNames, _getAxoSymbol } from './AxoSymbol';
export default {
title: 'Axo/AxoSymbol',
} satisfies Meta;
const SymbolInfo = memo(function SymbolInfo(props: {
symbolName: AxoSymbolName;
}): JSX.Element {
const ltr = _getAxoSymbol(props.symbolName, 'ltr');
const rtl = _getAxoSymbol(props.symbolName, 'rtl');
const variants =
ltr === rtl
? ([
// same
{ title: 'LTR/RTL', dir: 'ltr', text: ltr },
] as const)
: ([
{ title: 'LTR', dir: 'ltr', text: ltr },
{ title: 'RTL', dir: 'rtl', text: rtl },
] as const);
return (
<figure className="flex flex-col items-center gap-2 border border-border-primary bg-background-secondary p-4">
<div className="flex w-full flex-1 flex-row justify-between">
{variants.map(variant => {
return (
<div className="flex flex-1 flex-col items-center gap-2">
<span className="type-caption text-label-secondary">
{variant.title}
</span>
<span className="text-[20px] leading-none">
<Direction.DirectionProvider dir={variant.dir}>
<AxoSymbol.InlineGlyph
symbol={props.symbolName}
label={null}
/>
</Direction.DirectionProvider>
</span>
<code className="type-caption text-label-secondary">
{Array.from(variant.text, char => {
const codePoint = char.codePointAt(0) ?? -1;
return `U+${codePoint.toString(16).toUpperCase()}`;
}).join(' ')}
</code>
</div>
);
})}
</div>
<figcaption className="w-full truncate border-t border-dotted border-border-primary pt-4 text-center type-body-medium text-color-label-primary">
<code>{props.symbolName}</code>
</figcaption>
</figure>
);
});
const allAxoSymbolNames = _getAllAxoSymbolNames()
.slice()
.sort((a, b) => a.localeCompare(b));
const fuse = new Fuse(allAxoSymbolNames);
export function All(): JSX.Element {
const [input, setInput] = useState('');
const results = useMemo(() => {
if (input.trim() !== '') {
return fuse.search(input).map(result => {
return result.item;
});
}
return allAxoSymbolNames;
}, [input]);
return (
<>
<div className="sticky top-4 mb-3 bg-elevated-background-primary p-4 shadow-elevation-2">
<input
type="search"
value={input}
placeholder="Search..."
onChange={event => {
setInput(event.currentTarget.value);
}}
className="w-full rounded bg-elevated-background-secondary p-3 type-body-medium"
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6">
{results.map(result => {
return <SymbolInfo key={result} symbolName={result} />;
})}
</div>
</>
);
}

263
ts/axo/AxoSymbol.tsx Normal file
View file

@ -0,0 +1,263 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FC } from 'react';
import React, { memo } from 'react';
import { Direction } from 'radix-ui';
import { VisuallyHidden } from 'react-aria';
import { assert } from './_internal/assert';
const { useDirection } = Direction;
const Namespace = 'AxoSymbol';
type AxoSymbolDef = string | { ltr: string; rtl: string };
const AllAxoSymbolDefs = {
logo: '\u{E000}',
album: '\u{E001}',
appearance: '\u{E031}',
'arrow-[start]': { ltr: '\u{2190}', rtl: '\u{2192}' },
'arrow-[end]': { ltr: '\u{2192}', rtl: '\u{2190}' },
'arrow-up': '\u{2191}',
'arrow-down': '\u{2193}',
'arrow-up_[start]': { ltr: '\u{2196}', rtl: '\u{2197}' },
'arrow-up_[end]': { ltr: '\u{2197}', rtl: '\u{2196}' },
'arrow-down_[start]': { ltr: '\u{2199}', rtl: '\u{2198}' },
'arrow-down_[end]': { ltr: '\u{2198}', rtl: '\u{2199}' },
'arrow-circle-[start]': { ltr: '\u{E00B}', rtl: '\u{E00C}' },
'arrow-circle-[end]': { ltr: '\u{E00C}', rtl: '\u{E00B}' },
'arrow-circle-up': '\u{E00D}',
'arrow-circle-down': '\u{E00E}',
'arrow-circle-up_[start]': { ltr: '\u{E00F}', rtl: '\u{E010}' },
'arrow-circle-up_[end]': { ltr: '\u{E010}', rtl: '\u{E00F}' },
'arrow-circle-down_[start]': { ltr: '\u{E011}', rtl: '\u{E012}' },
'arrow-circle-down_[end]': { ltr: '\u{E012}', rtl: '\u{E011}' },
'arrow-square-[start]': { ltr: '\u{E013}', rtl: '\u{E014}' },
'arrow-square-[end]': { ltr: '\u{E014}', rtl: '\u{E013}' },
'arrow-square-up': '\u{E015}',
'arrow-square-down': '\u{E016}',
'arrow-square-up_[start]': { ltr: '\u{E017}', rtl: '\u{E018}' },
'arrow-square-up_[end]': { ltr: '\u{E018}', rtl: '\u{E017}' },
'arrow-square-down_[start]': { ltr: '\u{E019}', rtl: '\u{E01A}' },
'arrow-square-down_[end]': { ltr: '\u{E01A}', rtl: '\u{E019}' },
'arrow-dash-down': '\u{E021}',
'arrow-circle-[start]-fill': { ltr: '\u{E003}', rtl: '\u{E004}' },
'arrow-circle-[end]-fill': { ltr: '\u{E004}', rtl: '\u{E003}' },
'arrow-circle-up-fill': '\u{E005}',
'arrow-circle-down-fill': '\u{E006}',
'arrow-circle-up_[start]-fill': { ltr: '\u{E007}', rtl: '\u{E008}' },
'arrow-circle-up_[end]-fill': { ltr: '\u{E008}', rtl: '\u{E007}' },
'arrow-circle-down_[start]-fill': { ltr: '\u{E009}', rtl: '\u{E00A}' },
'arrow-circle-down_[end]-fill': { ltr: '\u{E00A}', rtl: '\u{E009}' },
'arrow-square-[start]-fill': { ltr: '\u{E08A}', rtl: '\u{E08B}' },
'arrow-square-[end]-fill': { ltr: '\u{E08B}', rtl: '\u{E08A}' },
'arrow-square-up-fill': '\u{E08C}',
'arrow-square-down-fill': '\u{E08D}',
'arrow-square-up_[start]-fill': { ltr: '\u{E08E}', rtl: '\u{E08F}' },
'arrow-square-up_[end]-fill': { ltr: '\u{E08F}', rtl: '\u{E08E}' },
'arrow-square-down_[start]-fill': { ltr: '\u{E090}', rtl: '\u{E091}' },
'arrow-square-down_[end]-fill': { ltr: '\u{E091}', rtl: '\u{E090}' },
at: '\u{E01B}',
attach: '\u{E058}',
audio: '\u{E01C}',
'audio-rectangle': '\u{E01D}',
badge: '\u{E099}',
'badge-fill': '\u{E09A}',
bell: '\u{E01E}',
'bell-slash': '\u{E01F}',
'bell-ring': '\u{E020}',
block: '\u{E002}',
calender: '\u{E0A2}',
'calender-blank': '\u{E0A3}',
check: '\u{2713}',
'check-circle': '\u{E022}',
'check-square': '\u{E023}',
'chevron-[start]': { ltr: '\u{E024}', rtl: '\u{E025}' },
'chevron-[end]': { ltr: '\u{E025}', rtl: '\u{E024}' },
'chevron-up': '\u{E026}',
'chevron-down': '\u{E027}',
'chevron-circle-[start]': { ltr: '\u{E028}', rtl: '\u{E029}' },
'chevron-circle-[end]': { ltr: '\u{E029}', rtl: '\u{E028}' },
'chevron-circle-up': '\u{E02A}',
'chevron-circle-down': '\u{E02B}',
'chevron-square-[start]': { ltr: '\u{E02C}', rtl: '\u{E02D}' },
'chevron-square-[end]': { ltr: '\u{E02D}', rtl: '\u{E02C}' },
'chevron-square-up': '\u{E02E}',
'chevron-square-down': '\u{E02F}',
'dropdown-down': '\u{E07F}',
'dropdown-up': '\u{E080}',
'dropdown-triangle-down': '\u{E082}',
'dropdown-triangle-up': '\u{E083}',
'dropdown-double': '\u{E081}',
edit: '\u{E030}',
emoji: '\u{263A}',
error: '\u{E032}',
'error-triangle': '\u{E092}',
'error-fill': '\u{E093}',
'error-triangle-fill': '\u{E094}',
file: '\u{E034}',
forward: '\u{E035}',
'forward-fill': '\u{E036}',
gif: '\u{E037}',
'gif-rectangle': '\u{E097}',
'gif-rectangle-fill': '\u{E098}',
gift: '\u{E0B5}',
globe: '\u{E0B6}',
group: '\u{E038}',
'group-x': '\u{E0AE}',
heart: '\u{E039}',
help: '\u{E0D8}',
incoming: '\u{E03A}',
info: '\u{E03B}',
leave: { ltr: '\u{E03C}', rtl: '\u{E03D}' },
link: '\u{E03E}',
'link-android': '\u{E03F}',
'link-broken': '\u{E057}',
'link-slash': '\u{E040}',
lock: '\u{E041}',
'lock-open': '\u{E07D}',
megaphone: '\u{E042}',
merge: '\u{E043}',
message: '\u{E0A6}',
'message_status-sending': '\u{E044}',
'message_status-sent': '\u{E045}',
'message_status-read': '\u{E047}',
'message_status-delivered': '\u{E046}',
'message_timer-00': '\u{E048}',
'message_timer-05': '\u{E049}',
'message_timer-10': '\u{E04A}',
'message_timer-15': '\u{E04B}',
'message_timer-20': '\u{E04C}',
'message_timer-25': '\u{E04D}',
'message_timer-30': '\u{E04E}',
'message_timer-35': '\u{E04F}',
'message_timer-40': '\u{E050}',
'message_timer-45': '\u{E051}',
'message_timer-50': '\u{E052}',
'message_timer-55': '\u{E053}',
'message_timer-60': '\u{E054}',
mic: '\u{E055}',
'mic-slash': '\u{E056}',
minus: '\u{2212}',
'minus-circle': '\u{2296}',
'minus-square': '\u{E059}',
'missed-incoming': '\u{E05A}',
'missed-outgoing': '\u{E05B}',
note: { ltr: '\u{E095}', rtl: '\u{E096}' },
official_badge: '\u{E086}',
'official_badge-fill': '\u{E087}',
outgoing: '\u{E05C}',
person: '\u{E05D}',
'person-circle': '\u{E05E}',
'person-check': '\u{E05F}',
'person-x': '\u{E060}',
'person-plus': '\u{E061}',
'person-minus': '\u{E062}',
'person-question': '\u{E06A}',
phone: '\u{E063}',
'phone-fill': '\u{E064}',
photo: '\u{E065}',
'photo-slash': '\u{E066}',
play: '\u{E067}',
'play-circle': '\u{E068}',
'play-square': '\u{E069}',
plus: '\u{002B}',
'plus-circle': '\u{2295}',
'plus-square': '\u{E06C}',
raise_hand: '\u{E07E}',
'raise_hand-fill': '\u{E084}',
refresh: '\u{E0C4}',
reply: '\u{E06D}',
'reply-fill': '\u{E06E}',
safety_number: '\u{E06F}',
spam: '\u{E033}',
sticker: '\u{E070}',
thread: '\u{E071}',
'thread-fill': '\u{E072}',
timer: '\u{E073}',
'timer-slash': '\u{E074}',
video_camera: '\u{E075}',
'video_camera-slash': '\u{E076}',
'video_camera-fill': '\u{E077}',
video: '\u{E088}',
'video-slash': '\u{E089}',
view_once: '\u{E078}',
'view_once-dash': '\u{E079}',
'view_once-viewed': '\u{E07A}',
x: '\u{00D7}',
'x-circle': '\u{2297}',
'x-square': '\u{2327}',
space: '\u{0020}',
} as const satisfies Record<string, AxoSymbolDef>;
export type AxoSymbolName = keyof typeof AllAxoSymbolDefs;
export function _getAllAxoSymbolNames(): ReadonlyArray<AxoSymbolName> {
return Object.keys(AllAxoSymbolDefs) as Array<AxoSymbolName>;
}
export function _getAxoSymbol(
symbolName: AxoSymbolName,
dir: 'ltr' | 'rtl'
): string {
const symbolDef = assert(
AllAxoSymbolDefs[symbolName],
`${Namespace}:Invalid name: ${symbolName}`
);
const symbol = typeof symbolDef === 'string' ? symbolDef : symbolDef[dir];
return symbol;
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace AxoSymbol {
/**
* Component: <AxoSymbol.InlineGlyph>
* --------------------------------------
*/
export type InlineGlyphProps = Readonly<{
symbol: AxoSymbolName;
label: string | null;
}>;
export const InlineGlyph: FC<InlineGlyphProps> = memo(props => {
const direction = useDirection();
const symbol = _getAxoSymbol(props.symbol, direction);
return (
<>
<span aria-hidden className="font-symbols select-none">
{symbol}
</span>
{props.label != null && (
<VisuallyHidden className="select-none">{props.label}</VisuallyHidden>
)}
</>
);
});
InlineGlyph.displayName = `${Namespace}.InlineGlyph`;
/**
* Component: <AxoSymbol.Icon>
* --------------------------------------
*/
export type IconProps = Readonly<{
size: 14 | 16 | 20;
symbol: AxoSymbolName;
label: string | null;
}>;
export const Icon: FC<IconProps> = memo(props => {
return (
<span
className="inline-flex size-[1em] shrink-0 items-center justify-center"
style={{ fontSize: props.size }}
>
<AxoSymbol.InlineGlyph symbol={props.symbol} label={props.label} />
</span>
);
});
Icon.displayName = `${Namespace}.Icon`;
}

View file

@ -0,0 +1,348 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { ReactNode } from 'react';
import { css } from './css';
import { AxoSymbol, type AxoSymbolName } from '../AxoSymbol';
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace AxoBaseMenu {
// <Content/SubContent>
const baseContentStyles = css(
'max-w-[300px] min-w-[200px] p-1.5',
'select-none',
'rounded-xl bg-elevated-background-tertiary shadow-elevation-3',
'data-[state=closed]:animate-fade-out'
);
const baseContentGridStyles = css('grid grid-cols-[min-content_auto]');
// <Group/RadioGroup>
const baseGroupStyles = css('col-span-full grid grid-cols-subgrid');
// <Item/RadioItem/CheckboxItem/SubTrigger/Label/Separator>
const baseItemStyles = css(
'col-span-full grid grid-cols-subgrid items-center'
);
// <Item/RadioItem/CheckboxItem/SubTrigger/Label> (not Separator)
const labeledItemStyles = css(baseItemStyles, 'truncate p-1.5');
// <Item/RadioItem/CheckboxItem/SubTrigger> (not Label/Separator)
const navigableItemStyles = css(
labeledItemStyles,
'rounded-md type-body-medium',
'outline-0 data-[highlighted]:bg-fill-secondary-pressed',
'data-[disabled]:text-label-disabled',
'outline-0 outline-border-focused focused:outline-[2.5px]'
);
/**
* <Item/RadioItem/CheckboxItem/SubTrigger> (not Label/Separator)
*/
type BaseNavigableItemProps = Readonly<{
/**
* When true, prevents the user from interacting with the item.
*/
disabled?: boolean;
/**
* Optional text used for typeahead purposes. By default the typeahead
* behavior will use the .textContent of the item. Use this when the
* content is complex, or you have non-textual content inside.
*/
textValue?: string;
/**
* An icon that should be rendered before the text.
*/
symbol?: AxoSymbolName;
}>;
// <Item/RadioItem/CheckboxItem> (not SubTrigger/Label/Separator)
const selectableItemStyles = css(navigableItemStyles);
/**
* Used for any selectable content node such as Item, CheckboxItem, or RadioItem,
* But not nodes like SubTrigger, Separator, Group, etc.
*/
type BaseSelectableItemProps = BaseNavigableItemProps &
Readonly<{
keyboardShortcut?: string;
onSelect?: (event: Event) => void;
}>;
/**
* AxoBaseMenu: Item Slots
* -----------------------
*/
export type ItemLeadingSlotProps = Readonly<{
children: ReactNode;
}>;
export function ItemLeadingSlot(props: ItemLeadingSlotProps): JSX.Element {
return (
<span className="col-start-1 col-end-1 me-1.5 flex items-center gap-1.5">
{props.children}
</span>
);
}
export type ItemContentSlotProps = Readonly<{
children: ReactNode;
}>;
export function ItemContentSlot(props: ItemContentSlotProps): JSX.Element {
return (
<span className="col-start-2 col-end-2 flex min-w-0 items-center">
{props.children}
</span>
);
}
/**
* AxoBaseMenu: Item Parts
* -----------------------
*/
export const itemTextStyles = css('flex-1 truncate text-start');
export type ItemTextProps = Readonly<{
children: ReactNode;
}>;
export function ItemText(props: ItemTextProps): JSX.Element {
return <span className={itemTextStyles}>{props.children}</span>;
}
export type ItemCheckPlaceholderProps = Readonly<{
children: ReactNode;
}>;
export function ItemCheckPlaceholder(
props: ItemCheckPlaceholderProps
): JSX.Element {
return <span className="w-3.5">{props.children}</span>;
}
export function ItemCheck(): JSX.Element {
return <AxoSymbol.Icon size={14} symbol="check" label={null} />;
}
export function ItemSymbol(props: { symbol: AxoSymbolName }): JSX.Element {
return <AxoSymbol.Icon size={16} symbol={props.symbol} label={null} />;
}
export type ItemKeyboardShortcutProps = Readonly<{
keyboardShortcut: string;
}>;
export function ItemKeyboardShortcut(
props: ItemKeyboardShortcutProps
): JSX.Element {
return (
<span className="ml-auto px-1 type-body-medium text-label-secondary">
{props.keyboardShortcut}
</span>
);
}
/**
* AxoBaseMenu: Root
* -----------------
*/
export type MenuRootProps = Readonly<{
children: ReactNode;
}>;
/**
* AxoBaseMenu: Trigger
* --------------------
*/
export type MenuTriggerProps = Readonly<{
/**
* When true, the context menu won't open when right-clicking.
* Note that this will also restore the native context menu.
*/
disabled?: boolean;
children: ReactNode;
}>;
/**
* AxoBaseMenu: Content
* --------------------
*/
export type MenuContentProps = Readonly<{
children: ReactNode;
}>;
export const menuContentStyles = css(
baseContentStyles,
baseContentGridStyles,
'max-h-(--radix-popper-available-height) overflow-auto [scrollbar-width:none]',
'overflow-auto [scrollbar-width:none]'
);
export const selectContentStyles = css(baseContentStyles);
export const selectContentViewportStyles = css(baseContentGridStyles);
/**
* AxoBaseMenu: Item
* -----------------
*/
export type MenuItemProps = BaseSelectableItemProps &
Readonly<{
/**
* Event handler called when the user selects an item (via mouse or
* keyboard). Calling event.preventDefault in this handler will prevent the
* context menu from closing when selecting that item.
*/
onSelect: (event: Event) => void;
children: ReactNode;
}>;
export const menuItemStyles = css(selectableItemStyles);
export const selectItemStyles = css(selectableItemStyles);
/**
* AxoBaseMenu: Group
* ------------------
*/
export type MenuGroupProps = Readonly<{
children: ReactNode;
}>;
export const menuGroupStyles = css(baseGroupStyles);
export const selectGroupStyles = css(baseGroupStyles);
/**
* AxoBaseMenu: Label
* ------------------
*/
export type MenuLabelProps = Readonly<{
children: ReactNode;
}>;
const baseLabelStyles = css(
labeledItemStyles,
'type-body-small text-label-secondary'
);
export const menuLabelStyles = css(baseLabelStyles);
export const selectLabelStyles = css(baseLabelStyles);
/**
* AxoBaseMenu: CheckboxItem
* -------------------------
*/
export type MenuCheckboxItemProps = BaseSelectableItemProps &
Readonly<{
/**
* The controlled checked state of the item. Must be used in conjunction
* with `onCheckedChange`.
*/
checked: boolean;
/**
* Event handler called when the checked state changes.
*/
onCheckedChange: (checked: boolean) => void;
children: ReactNode;
}>;
export const menuCheckboxItemStyles = css(selectableItemStyles);
/**
* AxoBaseMenu: RadioGroup
* -----------------------
*/
export type MenuRadioGroupProps = Readonly<{
/**
* The value of the selected item in the group.
*/
value: string;
/**
* Event handler called when the value changes.
*/
onValueChange: (value: string) => void;
children: ReactNode;
}>;
export const menuRadioGroupStyles = css(baseGroupStyles);
/**
* AxoBaseMenu: RadioItem
* ----------------------
*/
export type MenuRadioItemProps = BaseSelectableItemProps &
Readonly<{
value: string;
children: ReactNode;
}>;
export const menuRadioItemStyles = css(selectableItemStyles);
/**
* AxoBaseMenu: Separator
* ----------------------
*/
export type MenuSeparatorProps = Readonly<{
// N/A
}>;
const baseSeparatorStyles = css(
baseItemStyles,
'mx-0.5 my-1 border-t-[0.5px] border-border-primary'
);
export const menuSeparatorStyles = css(baseSeparatorStyles);
export const selectSeperatorStyles = css(baseSeparatorStyles);
/**
* AxoBaseMenu: Sub
* ----------------
*/
export type MenuSubProps = Readonly<{
children: ReactNode;
}>;
/**
* AxoBaseMenu: SubTrigger
* -----------------------
*/
export type MenuSubTriggerProps = BaseNavigableItemProps &
Readonly<{
children: ReactNode;
}>;
export const menuSubTriggerStyles = css(
navigableItemStyles,
'data-[state=open]:not-data-[highlighted]:bg-fill-secondary'
);
/**
* AxoBaseMenu: SubContent
* -----------------------
*/
export type MenuSubContentProps = Readonly<{
children: ReactNode;
}>;
export const menuSubContentStyles = css(
baseContentStyles,
'max-h-(--radix-popper-available-height) overflow-auto [scrollbar-width:none]',
baseContentGridStyles
);
}

View file

@ -0,0 +1,17 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export class AssertionError extends TypeError {
override name = 'AssertionError';
}
export function assert(condition: boolean, message?: string): asserts condition;
export function assert<T>(input: T, message?: string): NonNullable<T>;
export function assert<T>(input: T, message?: string): NonNullable<T> {
if (input === false || input == null) {
// eslint-disable-next-line no-debugger
debugger;
throw new AssertionError(message ?? `input is ${input}`);
}
return input;
}

27
ts/axo/_internal/css.tsx Normal file
View file

@ -0,0 +1,27 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export type Styles = string & { __Styles: never };
export function css(
...classNames: ReadonlyArray<Styles | string | boolean | null>
): Styles {
const { length } = classNames;
let result = '';
let first = true;
for (let index = 0; index < length; index += 1) {
const className = classNames[index];
if (typeof className === 'string') {
if (first) {
first = false;
} else {
result += ' ';
}
result += className;
}
}
return result as Styles;
}

376
ts/axo/tailwind.css Normal file
View file

@ -0,0 +1,376 @@
@import 'tailwindcss';
/**
* Custom Variants
* ----------------------------------------------------------------------------
*/
@custom-variant dark (&:where(.dark-theme, .dark-theme *));
@custom-variant hovered (&:hover:not(:disabled));
@custom-variant pressed (&:active:not(:disabled));
@custom-variant focused (.keyboard-mode &:focus);
/**
* Color
* ----------------------------------------------------------------------------
*/
/* prettier-ignore */
@theme {
--color-*: initial; /* reset defaults */
/* Colors/Labels */
--color-label-primary: light-dark(--alpha(#000 / 85%), --alpha(#FFF / 85%));
--color-label-secondary: light-dark(--alpha(#000 / 55%), --alpha(#FFF / 55%));
--color-label-placeholder: light-dark(--alpha(#000 / 30%), --alpha(#FFF / 30%));
--color-label-disabled: light-dark(--alpha(#000 / 20%), --alpha(#FFF / 20%));
--color-label-primary-inverted: light-dark(--alpha(#FFF / 85%), /* */ #000 /* */);
--color-label-secondary-inverted: light-dark(--alpha(#FFF / 55%), --alpha(#000 / 55%));
--color-label-placeholder-inverted: light-dark(--alpha(#FFF / 30%), --alpha(#000 / 30%));
--color-label-disabled-inverted: light-dark(--alpha(#FFF / 20%), --alpha(#000 / 20%));
--color-label-primary-on-color: light-dark(/* */ #FFF /* */, --alpha(#FFF / 90%));
--color-label-secondary-on-color: light-dark(--alpha(#FFF / 80%), --alpha(#FFF / 70%));
--color-label-placeholder-on-color: light-dark(--alpha(#FFF / 45%), --alpha(#FFF / 45%));
--color-label-disabled-on-color: light-dark(--alpha(#FFF / 35%), --alpha(#FFF / 30%));
/* Colors/Color Label */
--color-color-label-primary: light-dark(/* */ #030FFC /* */, /* */ #99A1FF /* */);
--color-color-label-primary-disabled: light-dark(--alpha(#030FFC / 25%), --alpha(#99A1FF / 25%));
--color-color-label-light: light-dark(/* */ #99A1FF /* */, /* */ #99A1FF /* */);
--color-color-label-light-disabled: light-dark(--alpha(#99A1FF / 25%), --alpha(#99A1FF / 25%));
--color-color-label-affirmative: light-dark(/* */ #00AD17 /* */, /* */ #30D150 /* */);
--color-color-label-affirmative-disabled: light-dark(--alpha(#00AD17 / 25%), --alpha(#30D150 / 25%));
--color-color-label-destructive: light-dark(/* */ #F21602 /* */, /* */ #FF4A3A /* */);
--color-color-label-destructive-disabled: light-dark(--alpha(#F21602 / 25%), --alpha(#FF4A3A / 25%));
/* Colors/Background */
--color-background-primary: light-dark(/* */ #FFFFFF /* */, /* */ #1A1A1A /* */);
--color-background-secondary: light-dark(/* */ #F6F6F6 /* */, /* */ #262626 /* */);
--color-background-overlay: light-dark(--alpha(#000000 / 20%), --alpha(#000000 / 40%));
/* Colors/Elevated Background */
--color-elevated-background-primary: light-dark(#FAFAFA, #2A2A2A);
--color-elevated-background-secondary: light-dark(#F2F2F2, #323232);
--color-elevated-background-tertiary: light-dark(#EAEAEA, #3A3A3A);
--color-elevated-background-quaternary: light-dark(#2A2A2A, #424242);
/* Colors/Fill */
--color-fill-primary: light-dark(/* */ #FFFFFF /* */, --alpha(#808080 / 20%));
--color-fill-primary-pressed: light-dark(/* */ #F6F6F6 /* */, --alpha(#808080 / 28%));
--color-fill-secondary: light-dark(--alpha(#808080 / 12%), --alpha(#808080 / 20%));
--color-fill-secondary-pressed: light-dark(--alpha(#808080 / 20%), --alpha(#808080 / 28%));
--color-fill-selected: light-dark(--alpha(#808080 / 25%), --alpha(#808080 / 32%));
--color-fill-inverted: light-dark(/* */ #424242 /* */, /* */ #DEDEDE /* */);
--color-fill-inverted-pressed: light-dark(/* */ #4E4E4E /* */, /* */ #CACACA /* */);
--color-fill-floating: light-dark(/* */ #FFFFFF /* */, /* */ #2A2A2A /* */);
--color-fill-floating-pressed: light-dark(/* */ #F6F6F6 /* */, /* */ #323232 /* */);
--color-fill-on-media: light-dark(--alpha(#000000 / 75%), --alpha(#000000 / 75%));
--color-fill-on-media-pressed: light-dark(--alpha(#000000 / 83%), --alpha(#000000 / 83%));
/* Colors/Message Fill */
--color-message-fill-incoming-primary: light-dark(/* */ #EAEAEA /* */, /* */ #3A3A3A /* */);
--color-message-fill-incoming-secondary: light-dark(--alpha(#FFFFFF / 80%), --alpha(#FFFFFF / 20%));
--color-message-fill-incoming-tertiary: light-dark(--alpha(#FFFFFF / 60%), --alpha(#FFFFFF / 12%));
--color-message-fill-outgoing-primary: light-dark(/* */ #2267F5 /* */, /* */ #2267F5 /* */);
--color-message-fill-outgoing-secondary: light-dark(--alpha(#FFFFFF / 60%), --alpha(#FFFFFF / 60%));
--color-message-fill-outgoing-tertiary: light-dark(--alpha(#FFFFFF / 20%), --alpha(#FFFFFF / 20%));
/* Colors/Color Fill */
--color-color-fill-primary: light-dark(#4655FF, #5563FF);
--color-color-fill-primary-pressed: light-dark(#3B4AF4, #4856F2);
--color-color-fill-affirmative: light-dark(#02C028, #02C529);
--color-color-fill-affirmative-pressed: light-dark(#00B324, #00B725);
--color-color-fill-warning: light-dark(#FFCC00, #FFD60A);
--color-color-fill-warning-pressed: light-dark(#FFCC00, #F1C900);
--color-color-fill-destructive: light-dark(#FD2512, #FB4332);
--color-color-fill-destructive-pressed: light-dark(#EB1300, #E93120);
/* Colors/Border */
--color-border-primary: light-dark(--alpha(#000000 / 16%), --alpha(#FFFFFF / 16%));
--color-border-secondary: light-dark(--alpha(#000000 / 08%), --alpha(#FFFFFF / 08%));
--color-border-focused: light-dark(/* */ #C1C7FE /* */, /* */ #C1C7FE /* */);
--color-border-selected: light-dark(/* */ #4655FF /* */, /* */ #5563FF /* */);
--color-border-selected-on-color: light-dark(/* */ #FFFFFF /* */, --alpha(#FFFFFF / 90%));
--color-border-error: light-dark(/* */ #FD2512 /* */, /* */ #FB4332 /* */);
/* Colors/Shadow */
--color-shadow-elevation-1: light-dark(--alpha(#000 / 08%), --alpha(#000 / 16%));
--color-shadow-elevation-2: light-dark(--alpha(#000 / 08%), --alpha(#000 / 16%));
--color-shadow-elevation-3: light-dark(--alpha(#000 / 10%), --alpha(#000 / 20%));
--color-shadow-elevation-4: light-dark(--alpha(#000 / 12%), --alpha(#000 / 24%));
--color-shadow-elevation-5: light-dark(--alpha(#000 / 20%), --alpha(#000 / 40%));
--color-shadow-outline: light-dark(--alpha(#000 / 12%), /* */ transparent);
--color-shadow-highlight: light-dark(/* */ transparent, --alpha(#FFF / 08%));
}
@layer base {
/* High Contrast Mode */
/* prettier-ignore */
@media (prefers-contrast: more) {
/* Colors/Labels */
--color-label-primary: light-dark(/* */ #000 /* */, /* */ #FFF /* */);
--color-label-secondary: light-dark(--alpha(#000 / 70%), --alpha(#FFF / 70%));
--color-label-placeholder: light-dark(--alpha(#000 / 50%), --alpha(#FFF / 50%));
--color-label-disabled: light-dark(--alpha(#000 / 40%), --alpha(#FFF / 40%));
--color-label-primary-inverted: light-dark(/* */ #FFF /* */, /* */ #000 /* */);
--color-label-secondary-inverted: light-dark(--alpha(#FFF / 70%), --alpha(#000 / 70%));
--color-label-placeholder-inverted: light-dark(--alpha(#FFF / 50%), --alpha(#000 / 50%));
--color-label-disabled-inverted: light-dark(--alpha(#FFF / 40%), --alpha(#000 / 40%));
--color-label-primary-on-color: light-dark(/* */ #FFF /* */, /* */ #FFF /* */);
--color-label-secondary-on-color: light-dark(--alpha(#FFF / 90%), --alpha(#FFF / 90%));
--color-label-placeholder-on-color: light-dark(--alpha(#FFF / 60%), --alpha(#FFF / 60%));
--color-label-disabled-on-color: light-dark(--alpha(#FFF / 50%), --alpha(#FFF / 50%));
/* Colors/Color Label */
--color-color-label-primary: light-dark(/* */ #000ECC /* */, /* */ #D5D9FF /* */);
--color-color-label-primary-disabled: light-dark(--alpha(#000ECC / 40%), --alpha(#D5D9FF / 40%));
--color-color-label-light: light-dark(/* */ #D5D9FF /* */, /* */ #D5D9FF /* */);
--color-color-label-light-disabled: light-dark(--alpha(#D5D9FF / 40%), --alpha(#D5D9FF / 40%));
--color-color-label-affirmative: light-dark(/* */ #004D0F /* */, /* */ #4CEF6D /* */);
--color-color-label-affirmative-disabled: light-dark(--alpha(#004D0F / 40%), --alpha(#4CEF6D / 40%));
--color-color-label-destructive: light-dark(/* */ #8A0B00 /* */, /* */ #FFC5C2 /* */);
--color-color-label-destructive-disabled: light-dark(--alpha(#8A0B00 / 40%), --alpha(#FFC5C2 / 40%));
/* Colors/Background */
--color-background-primary: light-dark(/* */ #FFFFFF /* */, /* */ #121212 /* */);
--color-background-secondary: light-dark(/* */ #F6F6F6 /* */, /* */ #1E1E1E /* */);
--color-background-overlay: light-dark(--alpha(#000000 / 40%), --alpha(#000000 / 60%));
/* Colors/Elevated Background */
--color-elevated-background-primary: light-dark(#FFFFFF, #222222);
--color-elevated-background-secondary: light-dark(#F2F2F2, #2A2A2A);
--color-elevated-background-tertiary: light-dark(#EAEAEA, #323232);
--color-elevated-background-quaternary: light-dark(#262626, #3A3A3A);
/* Colors/Fill */
--color-fill-primary: light-dark(/* */ #FFFFFF /* */, --alpha(#808080 / 30%));
--color-fill-primary-pressed: light-dark(/* */ #EAEAEA /* */, --alpha(#808080 / 38%));
--color-fill-secondary: light-dark(--alpha(#808080 / 22%), --alpha(#808080 / 30%));
--color-fill-secondary-pressed: light-dark(--alpha(#808080 / 30%), --alpha(#808080 / 38%));
--color-fill-selected: light-dark(--alpha(#808080 / 34%), --alpha(#808080 / 42%));
--color-fill-inverted: light-dark(/* */ #2A2A2A /* */, /* */ #F6F6F6 /* */);
--color-fill-inverted-pressed: light-dark(/* */ #363636 /* */, /* */ #E2E2E2 /* */);
--color-fill-floating: light-dark(/* */ #FFFFFF /* */, /* */ #323232 /* */);
--color-fill-floating-pressed: light-dark(/* */ #EAEAEA /* */, /* */ #3A3A3A /* */);
--color-fill-on-media: light-dark(--alpha(#000000 / 85%), --alpha(#000000 / 85%));
--color-fill-on-media-pressed: light-dark(--alpha(#000000 / 93%), --alpha(#000000 / 93%));
/* Colors/Message Fill */
--color-message-fill-incoming-primary: light-dark(/* */ #E0E0E0 /* */, /* */ #424242 /* */);
--color-message-fill-incoming-secondary: light-dark(--alpha(#FFFFFF / 90%), --alpha(#FFFFFF / 30%));
--color-message-fill-incoming-tertiary: light-dark(--alpha(#FFFFFF / 70%), --alpha(#FFFFFF / 22%));
--color-message-fill-outgoing-primary: light-dark(/* */ #0842B9 /* */, /* */ #0842B9 /* */);
--color-message-fill-outgoing-secondary: light-dark(--alpha(#FFFFFF / 70%), --alpha(#FFFFFF / 70%));
--color-message-fill-outgoing-tertiary: light-dark(--alpha(#FFFFFF / 30%), --alpha(#FFFFFF / 30%));
/* Colors/Color Fill */
--color-color-fill-primary: light-dark(#2B3BED, #2B3BED);
--color-color-fill-primary-pressed: light-dark(#1E2EE0, #1E2EE0);
--color-color-fill-affirmative: light-dark(#1D7A2F, #1D7A2F);
--color-color-fill-affirmative-pressed: light-dark(#115E23, #116E23);
--color-color-fill-warning: light-dark(#F0C000, #F0C000);
--color-color-fill-warning-pressed: light-dark(#E4B600, #E4B600);
--color-color-fill-destructive: light-dark(#B7271A, #B7271A);
--color-color-fill-destructive-pressed: light-dark(#A61609, #A61609);
/* Colors/Border */
--color-border-primary: light-dark(--alpha(#000000 / 32%), --alpha(#FFFFFF / 32%));
--color-border-secondary: light-dark(--alpha(#000000 / 16%), --alpha(#FFFFFF / 16%));
--color-border-focused: light-dark(/* */ #A0A7FE /* */, /* */ #A0A7FE /* */);
--color-border-selected: light-dark(/* */ #2B3BED /* */, /* */ #5563FF /* */);
--color-border-selected-on-color: light-dark(/* */ #FFFFFF /* */, /* */ #FFFFFF /* */);
--color-border-error: light-dark(/* */ #B7271A /* */, /* */ #FB4332 /* */);
/* Colors/Shadow */
--color-shadow-elevation-1: light-dark(--alpha(#000 / 08%), --alpha(#000 / 16%));
--color-shadow-elevation-2: light-dark(--alpha(#000 / 08%), --alpha(#000 / 16%));
--color-shadow-elevation-3: light-dark(--alpha(#000 / 10%), --alpha(#000 / 20%));
--color-shadow-elevation-4: light-dark(--alpha(#000 / 12%), --alpha(#000 / 24%));
--color-shadow-elevation-5: light-dark(--alpha(#000 / 20%), --alpha(#000 / 40%));
--color-shadow-outline: light-dark(--alpha(#000 / 32%), /* */ transparent);
--color-shadow-highlight: light-dark(/* */ transparent, --alpha(#FFF / 32%));
}
}
/**
* Font Family
* ----------------------------------------------------------------------------
*/
@theme {
--font-*: initial; /* reset defaults */
/* Note: --font-sans also has language */
--font-sans: Inter, 'Source Sans Pro', 'Source Han Sans', -apple-system,
system-ui, 'Segoe UI', 'Noto Sans', 'Helvetica Neue', Helvetica, Arial,
sans-serif;
/* Note: This font-family is checked for in matchMonospace, to support paste scenarios */
--font-mono: 'SF Mono', SFMono-Regular, ui-monospace, 'DejaVu Sans Mono',
Menlo, Consolas, monospace;
--font-symbols: 'SignalSymbols';
}
@font-face {
font-family: 'SignalSymbols';
font-style: normal;
font-weight: 300 400 700;
font-display: block;
src: url('../../fonts/signal-symbols/SignalSymbolsVariable.woff2');
}
@layer base {
/* Japanese */
:lang(ja) {
--font-sans: Inter, 'SF Pro', 'SF Pro JP', 'BIZ UDGothic',
'Hiragino Kaku Gothic Pro', 'ヒラギノ角ゴ Pro W3', メイリオ, Meiryo,
' Pゴシック', 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
/* Farsi (Persian) */
:lang(fa) {
--font-sans: 'Vazirmatn', -apple-system, system-ui, BlinkMacSystemFont,
'Segoe UI', Tahoma, 'Noto Sans Arabic', Helvetica, Arial, sans-serif;
}
/* Urdu */
:lang(ur) {
--font-sans: 'Noto Nastaliq Urdu', Gulzar, 'Jameel Noori Nastaleeq',
'Faiz Lahori Nastaleeq', 'Urdu Typesetting', Helvetica, Arial, sans-serif;
}
}
/**
* Typography
* ----------------------------------------------------------------------------
* Should prefer to use the `type-*` utility when possible.
*/
@theme {
/* text-size */
--text-*: initial; /* reset defaults */
--type-text-title-large: 1.5rem /* 24px */;
--type-text-title-medium: 1.125rem /* 18px */;
--type-text-title-small: 0.875rem /* 14px */;
--type-text-body-large: 0.875rem /* 14px */;
--type-text-body-medium: 0.8125rem /* 13px */;
--type-text-body-small: 0.75rem /* 12px */;
--type-text-caption: 0.6825rem /* 11px */;
/* font-weight */
--font-weight-*: initial; /* reset defaults */
--font-weight-semibold: 600;
--font-weight-medium: 500;
--font-weight-regular: 400;
--type-font-weight-title-large: var(--font-weight-semibold);
--type-font-weight-title-medium: var(--font-weight-semibold);
--type-font-weight-title-small: var(--font-weight-semibold);
--type-font-weight-body-large: var(--font-weight-regular);
--type-font-weight-body-medium: var(--font-weight-regular);
--type-font-weight-body-small: var(--font-weight-medium);
--type-font-weight-caption: var(--font-weight-regular);
/* letter-spacing */
--tracking-*: initial; /* reset defaults */
--type-tracking-title-large: -0.019em /* (@ 24px) -0.46px */;
--type-tracking-title-medium: -0.014em /* (@ 18px) -0.25px */;
--type-tracking-title-small: -0.006em /* (@ 14px) -0.08px */;
--type-tracking-body-large: -0.006em /* (@ 14px) -0.08px */;
--type-tracking-body-medium: -0.003em /* (@ 13px) -0.04px */;
--type-tracking-body-small: 0em /* (@ 12px) 0px */;
--type-tracking-caption: 0.005em /* (@ 11px) 0.05px */;
/* line-height */
--leading-*: initial; /* reset defaults */
--leading-none: 1;
--type-leading-title-large: 2rem /* 32px */;
--type-leading-title-medium: 1.5rem /* 24px */;
--type-leading-title-small: 1.25rem /* 20px */;
--type-leading-body-large: 1.25rem /* 20px */;
--type-leading-body-medium: 1.125rem /* 18px */;
--type-leading-body-small: 1rem /* 16px */;
--type-leading-caption: 0.875rem /* 14px */;
}
/* prettier-ignore */
@utility type-* {
font-size: --value(--type-text-*);
font-weight: --value(--type-font-weight-*);
letter-spacing: --value(--type-tracking-*);
line-height: --value(--type-leading-*);
}
/**
* Shadow
* ----------------------------------------------------------------------------
*/
/* prettier-ignore */
@theme {
/* box-shadow */
--shadow-*: initial; /* reset defaults */
--shadow-elevation-0:
0 1px 2px 0 var(--color-shadow-elevation-1);
--shadow-elevation-1:
0 0 0 0.5px var(--color-shadow-highlight) inset,
0 0 0 0.5px var(--color-shadow-outline),
0 2px 8px 0 var(--color-shadow-elevation-2);
--shadow-elevation-2:
0 0 0 0.5px var(--color-shadow-highlight) inset,
0 0 0 0.5px var(--color-shadow-outline),
0 4px 12px 0 var(--color-shadow-elevation-3);
--shadow-elevation-3:
0 0 0 0.5px var(--color-shadow-highlight) inset,
0 0 0 0.5px var(--color-shadow-outline),
0 6px 16px 0 var(--color-shadow-elevation-4);
--shadow-elevation-4:
0 0 0 0.5px var(--color-shadow-highlight) inset,
0 0 0 0.5px var(--color-shadow-outline),
0 12px 56px 0 var(--color-shadow-elevation-5);
/* box-shadow: inset */
--inset-shadow-*: initial; /* reset defaults */
/* filter: drop-shadow() */
--drop-shadow-*: initial; /* reset defaults */
--drop-shadow-elevation-0: var(--shadow-elevation-0);
--drop-shadow-elevation-1: var(--shadow-elevation-1);
--drop-shadow-elevation-2: var(--shadow-elevation-2);
--drop-shadow-elevation-3: var(--shadow-elevation-3);
--drop-shadow-elevation-4: var(--shadow-elevation-4);
}
/**
* Blur
* ----------------------------------------------------------------------------
*/
@theme {
/* filter/backdrop-filter: blur() */
--blur-*: initial; /* reset defaults */
--blur-thin: 10px;
--blur-regular: 40px;
--blur-thick: 80px;
}
/**
* Easing
* ----------------------------------------------------------------------------
*/
@theme {
--ease-*: initial; /* reset defaults */
--ease-in-cubic: cubic-bezier(0.32, 0, 0.67, 0);
--ease-out-cubic: cubic-bezier(0.33, 1, 0.68, 1);
--east-in-out-cubic: cubic-bezier(0.65, 0, 0.35, 1);
}
/**
* Animations
* ----------------------------------------------------------------------------
*/
@theme {
--animate-*: initial; /* reset defaults */
--animate-fade-out: animate-fade-out 120ms var(--ease-out-cubic);
}
@layer base {
@keyframes animate-fade-out {
to {
opacity: 0;
}
}
}