Init Axo design system
This commit is contained in:
parent
7553a85b1c
commit
0d99f8bca2
35 changed files with 4785 additions and 210 deletions
60
.eslintrc.js
60
.eslintrc.js
|
@ -317,6 +317,66 @@ module.exports = {
|
||||||
'func-names': 'off',
|
'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: {
|
rules: {
|
||||||
|
|
|
@ -17,6 +17,7 @@ ts/**/*.js
|
||||||
ts/protobuf/*.d.ts
|
ts/protobuf/*.d.ts
|
||||||
ts/protobuf/*.js
|
ts/protobuf/*.js
|
||||||
stylesheets/manifest.css
|
stylesheets/manifest.css
|
||||||
|
stylesheets/tailwind.css
|
||||||
ts/util/lint/exceptions.json
|
ts/util/lint/exceptions.json
|
||||||
storybook-static
|
storybook-static
|
||||||
build/locale-display-names.json
|
build/locale-display-names.json
|
||||||
|
|
|
@ -1,8 +1,19 @@
|
||||||
// Copyright 2018 Signal Messenger, LLC
|
// Copyright 2018 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
/** @type {import("prettier").Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
singleQuote: true,
|
singleQuote: true,
|
||||||
arrowParens: 'avoid',
|
arrowParens: 'avoid',
|
||||||
trailingComma: 'es5',
|
trailingComma: 'es5',
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ['./ts/axo/**.tsx'],
|
||||||
|
plugins: ['prettier-plugin-tailwindcss'],
|
||||||
|
options: {
|
||||||
|
tailwindStylesheet: './ts/axo/tailwind.css',
|
||||||
|
tailwindFunctions: ['css'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
|
@ -9,7 +9,7 @@ const config: StorybookConfig = {
|
||||||
reactDocgen: false,
|
reactDocgen: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
stories: ['../ts/components/**/*.stories.tsx'],
|
stories: ['../ts/axo/**/*.stories.tsx', '../ts/components/**/*.stories.tsx'],
|
||||||
|
|
||||||
addons: [
|
addons: [
|
||||||
'@storybook/addon-a11y',
|
'@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.node = { global: true };
|
||||||
|
|
||||||
config.externals = {
|
config.externals = {
|
||||||
|
|
|
@ -5,8 +5,8 @@ import '../ts/window.d.ts';
|
||||||
|
|
||||||
import React, { StrictMode } from 'react';
|
import React, { StrictMode } from 'react';
|
||||||
|
|
||||||
import 'sanitize.css';
|
|
||||||
import '../stylesheets/manifest.scss';
|
import '../stylesheets/manifest.scss';
|
||||||
|
import '../ts/axo/tailwind.css';
|
||||||
|
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
import messages from '../_locales/en/messages.json';
|
import messages from '../_locales/en/messages.json';
|
||||||
|
|
|
@ -4818,6 +4818,30 @@ Signal Desktop makes use of the following open source projects.
|
||||||
|
|
||||||
License: MIT
|
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
|
## react
|
||||||
|
|
||||||
MIT License
|
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
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
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 person’s 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 Affirmer’s 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 Affirmer’s 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 Affirmer’s 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
|
|
||||||
Affirmer’s 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 Affirmer’s 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 Affirmer’s 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 person’s 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
|
## semver
|
||||||
|
|
||||||
The ISC License
|
The ISC License
|
||||||
|
|
|
@ -15,12 +15,8 @@
|
||||||
script-src 'self';
|
script-src 'self';
|
||||||
style-src 'self' 'unsafe-inline';"
|
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/manifest.css" rel="stylesheet" type="text/css" />
|
||||||
|
<link href="stylesheets/tailwind.css" rel="stylesheet" type="text/css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
@ -76,6 +76,13 @@
|
||||||
type="font/woff2"
|
type="font/woff2"
|
||||||
crossorigin
|
crossorigin
|
||||||
/>
|
/>
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="fonts/signal-symbols/SignalSymbolsVariable.woff2"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin
|
||||||
|
/>
|
||||||
<link
|
<link
|
||||||
rel="preload"
|
rel="preload"
|
||||||
href="images/logo-parts/base.svg"
|
href="images/logo-parts/base.svg"
|
||||||
|
@ -89,18 +96,13 @@
|
||||||
as="image"
|
as="image"
|
||||||
crossorigin
|
crossorigin
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<link
|
|
||||||
href="node_modules/sanitize.css/sanitize.css"
|
|
||||||
rel="stylesheet"
|
|
||||||
type="text/css"
|
|
||||||
/>
|
|
||||||
<link
|
<link
|
||||||
href="node_modules/@signalapp/quill-cjs/dist/quill.core.css"
|
href="node_modules/@signalapp/quill-cjs/dist/quill.core.css"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
type="text/css"
|
type="text/css"
|
||||||
/>
|
/>
|
||||||
<link href="stylesheets/manifest.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>
|
</head>
|
||||||
<body class="overflow-hidden">
|
<body class="overflow-hidden">
|
||||||
<!-- Match ts/components/App.tsx -->
|
<!-- Match ts/components/App.tsx -->
|
||||||
|
|
|
@ -15,12 +15,8 @@
|
||||||
script-src 'self';
|
script-src 'self';
|
||||||
style-src 'self' 'unsafe-inline';"
|
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/manifest.css" rel="stylesheet" type="text/css" />
|
||||||
|
<link href="stylesheets/tailwind.css" rel="stylesheet" type="text/css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
BIN
fonts/signal-symbols/SignalSymbolsVariable.woff2
Normal file
BIN
fonts/signal-symbols/SignalSymbolsVariable.woff2
Normal file
Binary file not shown.
|
@ -16,12 +16,8 @@
|
||||||
script-src 'self';
|
script-src 'self';
|
||||||
style-src 'self' 'unsafe-inline';"
|
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/manifest.css" rel="stylesheet" type="text/css" />
|
||||||
|
<link href="stylesheets/tailwind.css" rel="stylesheet" type="text/css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-migration-screen app-loading-screen">
|
<div class="app-migration-screen app-loading-screen">
|
||||||
|
|
23
package.json
23
package.json
|
@ -18,7 +18,7 @@
|
||||||
"postinstall": "pnpm run build:acknowledgments && pnpm run electron:install-app-deps",
|
"postinstall": "pnpm run build:acknowledgments && pnpm run electron:install-app-deps",
|
||||||
"postuninstall": "pnpm run build:acknowledgments",
|
"postuninstall": "pnpm run build:acknowledgments",
|
||||||
"start": "electron .",
|
"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",
|
"build-release": "pnpm run build",
|
||||||
"sign-release": "node ts/updater/generateSignature.js",
|
"sign-release": "node ts/updater/generateSignature.js",
|
||||||
"notarize": "echo 'No longer necessary'",
|
"notarize": "echo 'No longer necessary'",
|
||||||
|
@ -30,7 +30,6 @@
|
||||||
"mark-unusued-strings-deleted": "ts-node ./ts/scripts/mark-unused-strings-deleted.ts",
|
"mark-unusued-strings-deleted": "ts-node ./ts/scripts/mark-unused-strings-deleted.ts",
|
||||||
"get-expire-time": "node ts/scripts/get-expire-time.js",
|
"get-expire-time": "node ts/scripts/get-expire-time.js",
|
||||||
"copy-components": "node ts/scripts/copy.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",
|
"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",
|
"clean-module-protobuf": "rm -f ts/protobuf/compiled.d.ts ts/protobuf/compiled.js",
|
||||||
"build-protobuf": "pnpm run build-module-protobuf",
|
"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": "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:transpile": "run-p \"check:types --watch\" dev:esbuild dev:icu-types dev:protobuf",
|
||||||
"dev:esbuild": "node scripts/esbuild.js --watch",
|
"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:icu-types": "chokidar ./_locales/en/messages.json --initial --command \"pnpm run build:icu-types\"",
|
||||||
"dev:protobuf": "chokidar ./protos/**/*.proto --command \"pnpm run build-protobuf\"",
|
"dev:protobuf": "chokidar ./protos/**/*.proto --command \"pnpm run build-protobuf\"",
|
||||||
"build:storybook": "pnpm run build-protobuf && cross-env SIGNAL_ENV=storybook storybook build",
|
"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:dev": "run-s --print-label generate build:esbuild:prod",
|
||||||
"build:esbuild": "node scripts/esbuild.js",
|
"build:esbuild": "node scripts/esbuild.js",
|
||||||
"build:esbuild:prod": "node scripts/esbuild.js --prod",
|
"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: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": "cross-env SIGNAL_ENV=production pnpm run build:electron --config.directories.output=release",
|
||||||
"build:release-win32-all": "pnpm run build:release --arm64 --x64",
|
"build:release-win32-all": "pnpm run build:release --arm64 --x64",
|
||||||
|
@ -183,6 +187,7 @@
|
||||||
"protobufjs": "7.3.2",
|
"protobufjs": "7.3.2",
|
||||||
"proxy-agent": "6.4.0",
|
"proxy-agent": "6.4.0",
|
||||||
"qrcode-generator": "1.4.4",
|
"qrcode-generator": "1.4.4",
|
||||||
|
"radix-ui": "1.4.2",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.35.1",
|
"react-aria": "3.35.1",
|
||||||
"react-aria-components": "1.4.1",
|
"react-aria-components": "1.4.1",
|
||||||
|
@ -199,7 +204,6 @@
|
||||||
"redux-promise-middleware": "6.2.0",
|
"redux-promise-middleware": "6.2.0",
|
||||||
"redux-thunk": "3.1.0",
|
"redux-thunk": "3.1.0",
|
||||||
"reselect": "5.1.1",
|
"reselect": "5.1.1",
|
||||||
"sanitize.css": "13.0.0",
|
|
||||||
"semver": "7.6.3",
|
"semver": "7.6.3",
|
||||||
"split2": "4.2.0",
|
"split2": "4.2.0",
|
||||||
"tinykeys": "3.0.0",
|
"tinykeys": "3.0.0",
|
||||||
|
@ -244,6 +248,8 @@
|
||||||
"@storybook/test": "8.4.4",
|
"@storybook/test": "8.4.4",
|
||||||
"@storybook/test-runner": "0.22.0",
|
"@storybook/test-runner": "0.22.0",
|
||||||
"@storybook/types": "8.1.11",
|
"@storybook/types": "8.1.11",
|
||||||
|
"@tailwindcss/cli": "4.1.7",
|
||||||
|
"@tailwindcss/postcss": "4.1.7",
|
||||||
"@types/backbone": "1.4.22",
|
"@types/backbone": "1.4.22",
|
||||||
"@types/blueimp-load-image": "5.16.6",
|
"@types/blueimp-load-image": "5.16.6",
|
||||||
"@types/chai": "4.3.16",
|
"@types/chai": "4.3.16",
|
||||||
|
@ -305,6 +311,7 @@
|
||||||
"eslint": "8.56.0",
|
"eslint": "8.56.0",
|
||||||
"eslint-config-airbnb-typescript-prettier": "5.0.0",
|
"eslint-config-airbnb-typescript-prettier": "5.0.0",
|
||||||
"eslint-config-prettier": "8.5.0",
|
"eslint-config-prettier": "8.5.0",
|
||||||
|
"eslint-plugin-better-tailwindcss": "3.7.2",
|
||||||
"eslint-plugin-import": "2.26.0",
|
"eslint-plugin-import": "2.26.0",
|
||||||
"eslint-plugin-local-rules": "1.3.2",
|
"eslint-plugin-local-rules": "1.3.2",
|
||||||
"eslint-plugin-mocha": "10.1.0",
|
"eslint-plugin-mocha": "10.1.0",
|
||||||
|
@ -324,7 +331,10 @@
|
||||||
"pixelmatch": "5.3.0",
|
"pixelmatch": "5.3.0",
|
||||||
"playwright": "1.45.0",
|
"playwright": "1.45.0",
|
||||||
"pngjs": "7.0.0",
|
"pngjs": "7.0.0",
|
||||||
|
"postcss": "8.5.3",
|
||||||
|
"postcss-loader": "8.1.1",
|
||||||
"prettier": "3.3.3",
|
"prettier": "3.3.3",
|
||||||
|
"prettier-plugin-tailwindcss": "0.6.11",
|
||||||
"protobufjs-cli": "1.1.1",
|
"protobufjs-cli": "1.1.1",
|
||||||
"react-devtools": "6.0.1",
|
"react-devtools": "6.0.1",
|
||||||
"react-devtools-core": "6.0.1",
|
"react-devtools-core": "6.0.1",
|
||||||
|
@ -340,6 +350,7 @@
|
||||||
"stylelint-config-recommended-scss": "14.1.0",
|
"stylelint-config-recommended-scss": "14.1.0",
|
||||||
"stylelint-use-logical-spec": "5.0.1",
|
"stylelint-use-logical-spec": "5.0.1",
|
||||||
"svgo": "3.3.2",
|
"svgo": "3.3.2",
|
||||||
|
"tailwindcss": "4.1.7",
|
||||||
"terser-webpack-plugin": "5.3.10",
|
"terser-webpack-plugin": "5.3.10",
|
||||||
"ts-node": "10.9.2",
|
"ts-node": "10.9.2",
|
||||||
"typescript": "5.6.3",
|
"typescript": "5.6.3",
|
||||||
|
@ -379,7 +390,8 @@
|
||||||
"node-fetch@2.6.7": "patches/node-fetch+2.6.7.patch",
|
"node-fetch@2.6.7": "patches/node-fetch+2.6.7.patch",
|
||||||
"zod@3.23.8": "patches/zod+3.23.8.patch",
|
"zod@3.23.8": "patches/zod+3.23.8.patch",
|
||||||
"app-builder-lib": "patches/app-builder-lib.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": [
|
"onlyBuiltDependencies": [
|
||||||
"@indutny/mac-screen-share",
|
"@indutny/mac-screen-share",
|
||||||
|
@ -390,6 +402,7 @@
|
||||||
"@signalapp/ringrtc",
|
"@signalapp/ringrtc",
|
||||||
"@signalapp/windows-ucv",
|
"@signalapp/windows-ucv",
|
||||||
"@swc/core",
|
"@swc/core",
|
||||||
|
"@tailwindcss/oxide",
|
||||||
"bufferutil",
|
"bufferutil",
|
||||||
"electron",
|
"electron",
|
||||||
"esbuild",
|
"esbuild",
|
||||||
|
|
13
patches/eslint-plugin-better-tailwindcss.patch
Normal file
13
patches/eslint-plugin-better-tailwindcss.patch
Normal 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;
|
|
@ -15,12 +15,8 @@
|
||||||
script-src 'self';
|
script-src 'self';
|
||||||
style-src 'self' 'unsafe-inline';"
|
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/manifest.css" rel="stylesheet" type="text/css" />
|
||||||
|
<link href="stylesheets/tailwind.css" rel="stylesheet" type="text/css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
1886
pnpm-lock.yaml
generated
1886
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
@ -15,12 +15,8 @@
|
||||||
script-src 'self';
|
script-src 'self';
|
||||||
style-src 'self' 'unsafe-inline';"
|
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/manifest.css" rel="stylesheet" type="text/css" />
|
||||||
|
<link href="stylesheets/tailwind.css" rel="stylesheet" type="text/css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
@ -15,12 +15,8 @@
|
||||||
script-src 'self';
|
script-src 'self';
|
||||||
style-src 'self' 'unsafe-inline';"
|
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/manifest.css" rel="stylesheet" type="text/css" />
|
||||||
|
<link href="stylesheets/tailwind.css" rel="stylesheet" type="text/css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
@ -7,6 +7,8 @@
|
||||||
html {
|
html {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
cursor: inherit;
|
cursor: inherit;
|
||||||
|
// Legacy style from sanitize.css:
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.light-theme {
|
.light-theme {
|
||||||
|
@ -157,10 +159,13 @@ audio {
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
button:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
button.grey {
|
button.grey {
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
border: solid 1px variables.$color-gray-25;
|
border: solid 1px variables.$color-gray-25;
|
||||||
|
|
|
@ -20,6 +20,7 @@
|
||||||
padding-inline: 12px 32px;
|
padding-inline: 12px 32px;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
color: variables.$color-black;
|
||||||
|
|
||||||
@include mixins.dark-theme {
|
@include mixins.dark-theme {
|
||||||
background-color: variables.$color-gray-90;
|
background-color: variables.$color-gray-90;
|
||||||
|
|
|
@ -43,6 +43,7 @@ $input-padding-inline: 12px;
|
||||||
$input-padding-inline + $icon-actual-size + $icon-margin-inline-start
|
$input-padding-inline + $icon-actual-size + $icon-margin-inline-start
|
||||||
);
|
);
|
||||||
@include mixins.font-body-1;
|
@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);
|
background: light-dark(variables.$color-gray-05, variables.$color-gray-80);
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
<title>TextSecure test runner</title>
|
<title>TextSecure test runner</title>
|
||||||
<link rel="stylesheet" href="../node_modules/mocha/mocha.css" />
|
<link rel="stylesheet" href="../node_modules/mocha/mocha.css" />
|
||||||
<link rel="stylesheet" href="../stylesheets/manifest.css" />
|
<link rel="stylesheet" href="../stylesheets/manifest.css" />
|
||||||
|
<link rel="stylesheet" href="../stylesheets/tailwind.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="mocha"></div>
|
<div id="mocha"></div>
|
||||||
|
|
90
ts/axo/AxoButton.stories.tsx
Normal file
90
ts/axo/AxoButton.stories.tsx
Normal 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
195
ts/axo/AxoButton.tsx
Normal 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;
|
||||||
|
}
|
99
ts/axo/AxoContextMenu.stories.tsx
Normal file
99
ts/axo/AxoContextMenu.stories.tsx
Normal 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
388
ts/axo/AxoContextMenu.tsx
Normal 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`;
|
||||||
|
}
|
100
ts/axo/AxoDropdownMenu.stories.tsx
Normal file
100
ts/axo/AxoDropdownMenu.stories.tsx
Normal 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
402
ts/axo/AxoDropdownMenu.tsx
Normal 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 user—such as a set of actions or functions—triggered
|
||||||
|
* 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`;
|
||||||
|
}
|
90
ts/axo/AxoSelect.stories.tsx
Normal file
90
ts/axo/AxoSelect.stories.tsx
Normal 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
288
ts/axo/AxoSelect.tsx
Normal 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 from—triggered 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`;
|
||||||
|
}
|
102
ts/axo/AxoSymbol.stories.tsx
Normal file
102
ts/axo/AxoSymbol.stories.tsx
Normal 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
263
ts/axo/AxoSymbol.tsx
Normal 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`;
|
||||||
|
}
|
348
ts/axo/_internal/AxoBaseMenu.tsx
Normal file
348
ts/axo/_internal/AxoBaseMenu.tsx
Normal 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
|
||||||
|
);
|
||||||
|
}
|
17
ts/axo/_internal/assert.tsx
Normal file
17
ts/axo/_internal/assert.tsx
Normal 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
27
ts/axo/_internal/css.tsx
Normal 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
376
ts/axo/tailwind.css
Normal 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,
|
||||||
|
'MS 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue