Init Axo design system

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

View file

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

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

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

View file

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

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

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

View file

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

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

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

View file

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

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

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

View file

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

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

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

View file

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

View file

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

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

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

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

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