Init Axo design system
This commit is contained in:
parent
7553a85b1c
commit
0d99f8bca2
35 changed files with 4785 additions and 210 deletions
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