mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-05-17 13:20:23 +01:00
Axo improvements and documentation
This commit is contained in:
+5
-18
@@ -3,8 +3,6 @@
|
||||
|
||||
import '../ts/window.d.ts';
|
||||
|
||||
import { StrictMode } from 'react';
|
||||
|
||||
import '@signalapp/quill-cjs/dist/quill.core.css';
|
||||
import '../stylesheets/manifest.scss';
|
||||
import '../stylesheets/tailwind-config.css';
|
||||
@@ -20,7 +18,7 @@ import { StorybookThemeContext } from './StorybookThemeContext.std.ts';
|
||||
import { SystemThemeType, ThemeType } from '../ts/types/Util.std.ts';
|
||||
import { setupI18n } from '../ts/util/setupI18n.dom.tsx';
|
||||
import { HourCyclePreference } from '../ts/types/I18N.std.ts';
|
||||
import { AxoProvider } from '../ts/axo/AxoProvider.dom.tsx';
|
||||
import { AppProvider } from '../ts/windows/AppProvider.dom.tsx';
|
||||
import type { StateType } from '../ts/state/reducer.preload.ts';
|
||||
import {
|
||||
ScrollerLockContext,
|
||||
@@ -185,14 +183,6 @@ window.Signal = {
|
||||
},
|
||||
};
|
||||
|
||||
function withStrictMode(Story, context) {
|
||||
return (
|
||||
<StrictMode>
|
||||
<Story {...context} />
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
const withGlobalTypesProvider = (Story, context) => {
|
||||
const theme =
|
||||
context.globals.theme === 'light' ? ThemeType.light : ThemeType.dark;
|
||||
@@ -285,19 +275,16 @@ function withFunProvider(Story, context) {
|
||||
);
|
||||
}
|
||||
|
||||
function withAxoProvider(Story, context) {
|
||||
const globalValue = context.globals.direction ?? 'ltr';
|
||||
const dir = globalValue === 'auto' ? 'ltr' : globalValue;
|
||||
function withAppProvider(Story, context) {
|
||||
return (
|
||||
<AxoProvider dir={dir}>
|
||||
<AppProvider>
|
||||
<Story {...context} />
|
||||
</AxoProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const decorators = [
|
||||
withStrictMode,
|
||||
withAxoProvider,
|
||||
withAppProvider,
|
||||
withGlobalTypesProvider,
|
||||
withMockStoreProvider,
|
||||
withScrollLockProvider,
|
||||
|
||||
@@ -11,6 +11,22 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"icu:AxoButton.Pending": {
|
||||
"messageformat": "Pending",
|
||||
"description": "Axo Design System > Button Component > Pending State > Generic accessibility label"
|
||||
},
|
||||
"icu:AxoDialog.Back": {
|
||||
"messageformat": "Back",
|
||||
"description": "Axo Design System > Dialog Component > Back Button > Generic accessibility label"
|
||||
},
|
||||
"icu:AxoDialog.Close": {
|
||||
"messageformat": "Close",
|
||||
"description": "Axo Design System > Dialog Component > Close Button > Generic accessibility label"
|
||||
},
|
||||
"icu:AxoTextField.Clear": {
|
||||
"messageformat": "Clear",
|
||||
"description": "Axo Design System > Text Field Component > Clear Button > Generic accessibility label"
|
||||
},
|
||||
"icu:AddUserToAnotherGroupModal__title": {
|
||||
"messageformat": "Add to a group",
|
||||
"description": "Shown as the title of the dialog that allows you to add a contact to an group"
|
||||
|
||||
@@ -52,10 +52,7 @@ function CardSeeMoreLink(props: { onClick: () => void; children: ReactNode }) {
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
<AriaClickable.HiddenTrigger
|
||||
aria-labelledby={id}
|
||||
onClick={props.onClick}
|
||||
/>
|
||||
<AriaClickable.HiddenTrigger labelledby={id} onClick={props.onClick} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+133
-119
@@ -1,14 +1,7 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
forwardRef,
|
||||
} from 'react';
|
||||
import type { ReactNode, MouseEvent, FC, ForwardedRef } from 'react';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { ReactNode, MouseEvent, FC, Ref } from 'react';
|
||||
import { useLayoutEffect, mergeRefs } from '@react-aria/utils';
|
||||
import { computeAccessibleName } from 'dom-accessibility-api';
|
||||
import { tw } from './tw.dom.tsx';
|
||||
@@ -19,58 +12,68 @@ import {
|
||||
} from './_internal/StrictContext.dom.tsx';
|
||||
import { isTestOrMockEnvironment } from '../environment.std.ts';
|
||||
|
||||
const Namespace = 'AriaClickable';
|
||||
|
||||
/**
|
||||
* Makes an arbitrary region clickable as a single accessible button, while
|
||||
* allowing nested interactive widgets (buttons, links) to remain independently
|
||||
* focusable and clickable.
|
||||
*
|
||||
* The `HiddenTrigger` is an invisible full-area `<button>` that sits below
|
||||
* any `SubWidget` children. It propagates hover/press/focus state as
|
||||
* `data-hovered`, `data-pressed`, and `data-focused` attributes on `Root`
|
||||
* so the containing element can style itself accordingly.
|
||||
*
|
||||
* @example Anatomy
|
||||
* ```tsx
|
||||
* export default () => (
|
||||
* <AriaClickable.Root>
|
||||
* <h3>Card Title</h3>
|
||||
* <p>
|
||||
* Lorem ipsum dolor sit amet consectetur adipisicing elit...
|
||||
* <span id="see-more-1">See more</span>
|
||||
* <AriaClickable.HiddenTrigger aria-labelledby="see-more-1"/>
|
||||
* </p>
|
||||
* <AriaClickable.SubWidget>
|
||||
* <AxoButton.Root>Delete</AxoButton.Root>
|
||||
* </AriaClickable.SubWidget>
|
||||
* <AriaClickable.SubWidget>
|
||||
* <AxoLink>Edit</AxoLink>
|
||||
* </AriaClickable.SubWidget>
|
||||
* </AriaClickable.Root>
|
||||
* );
|
||||
* <AriaClickable.Root>
|
||||
* <h3>Card Title</h3>
|
||||
* <p>
|
||||
* Lorem ipsum dolor sit amet consectetur adipisicing elit...
|
||||
* <span id="see-more-1">See more</span>
|
||||
* <AriaClickable.HiddenTrigger labelledby="see-more-1" onClick={onOpen} />
|
||||
* </p>
|
||||
* <AriaClickable.SubWidget>
|
||||
* <AxoButton.Root variant="subtle-destructive" size="sm" onClick={onDelete}>Delete</AxoButton.Root>
|
||||
* </AriaClickable.SubWidget>
|
||||
* </AriaClickable.Root>
|
||||
* ```
|
||||
*/
|
||||
export namespace AriaClickable {
|
||||
/** @internal */
|
||||
type TriggerState = Readonly<{
|
||||
hovered: boolean;
|
||||
pressed: boolean;
|
||||
focused: boolean;
|
||||
}>;
|
||||
|
||||
/** @internal */
|
||||
const INITIAL_TRIGGER_STATE: TriggerState = {
|
||||
hovered: false,
|
||||
pressed: false,
|
||||
focused: false,
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
type TriggerStateUpdate = (state: TriggerState) => void;
|
||||
|
||||
const TriggerStateUpdateContext = createStrictContext<TriggerStateUpdate>(
|
||||
`${Namespace}.Root`
|
||||
);
|
||||
/** @internal */
|
||||
const TriggerStateUpdateContext =
|
||||
createStrictContext<TriggerStateUpdate>('AriaClickable.Root');
|
||||
|
||||
/**
|
||||
* Component: <AriaClickable.Root>
|
||||
* -------------------------------
|
||||
* <AriaClickable.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type RootProps = Readonly<{
|
||||
/** Additional CSS classes applied to the container `<div>`. */
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A `position: relative` container that exposes `data-hovered`, `data-pressed`,
|
||||
* and `data-focused` attributes driven by the `HiddenTrigger` state.
|
||||
*/
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
const [hovered, setHovered] = useState(INITIAL_TRIGGER_STATE.hovered);
|
||||
const [pressed, setPressed] = useState(INITIAL_TRIGGER_STATE.pressed);
|
||||
@@ -98,11 +101,11 @@ export namespace AriaClickable {
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = `${Namespace}.Root`;
|
||||
Root.displayName = 'AriaClickable.Root';
|
||||
|
||||
/**
|
||||
* Component: <AriaClickable.SubAction>
|
||||
* ------------------------------------
|
||||
* <AriaClickable.SubWidget>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type SubWidgetProps = Readonly<{
|
||||
@@ -144,9 +147,15 @@ export namespace AriaClickable {
|
||||
);
|
||||
});
|
||||
|
||||
SubWidget.displayName = `${Namespace}.SubWidget`;
|
||||
SubWidget.displayName = 'AriaClickable.SubWidget';
|
||||
|
||||
/**
|
||||
* <AriaClickable.DeadArea>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type DeadAreaProps = Readonly<{
|
||||
/** Additional CSS classes for sizing/positioning the dead area. */
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
@@ -167,18 +176,19 @@ export namespace AriaClickable {
|
||||
);
|
||||
});
|
||||
|
||||
DeadArea.displayName = `${Namespace}.DeadArea`;
|
||||
DeadArea.displayName = 'AriaClickable.DeadArea';
|
||||
|
||||
/**
|
||||
* Component: <AriaClickable.HiddenTrigger>
|
||||
* ------------------------------------
|
||||
* <AriaClickable.HiddenTrigger>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type HiddenTriggerProps = Readonly<{
|
||||
/**
|
||||
* Describe the action to be taken `onClick`
|
||||
*/
|
||||
'aria-label'?: string;
|
||||
/** Ref to the underlying `<button>` element. */
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
|
||||
/** Describe the action to be taken `onClick` */
|
||||
label?: string;
|
||||
/**
|
||||
* This should reference the ID of an element that describes the action that
|
||||
* will be taken `onClick`, not the entire clickable root.
|
||||
@@ -186,16 +196,14 @@ export namespace AriaClickable {
|
||||
* @example
|
||||
* ```tsx
|
||||
* <span id="see-more-1">See more</span>
|
||||
* <HiddenTrigger aria-labelledby="see-more-1"/>
|
||||
* <HiddenTrigger labelledby="see-more-1"/>
|
||||
* ```
|
||||
*/
|
||||
'aria-labelledby'?: string;
|
||||
onClick: (event: MouseEvent) => void;
|
||||
// Note: Technically we forward all props for Radix, but we restrict the
|
||||
// props that the type accepts
|
||||
}>;
|
||||
labelledby?: string;
|
||||
|
||||
const hiddenTriggerDisplayName = `${Namespace}.HiddenTrigger`;
|
||||
/** Called when the button is clicked. */
|
||||
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Provides an invisible button that fills the entire area of
|
||||
@@ -206,82 +214,88 @@ export namespace AriaClickable {
|
||||
* - This should be inserted in the expected focus order, which is likely
|
||||
* before any <AriaClickable.SubWidget>.
|
||||
*/
|
||||
export const HiddenTrigger = memo(
|
||||
forwardRef(
|
||||
(props: HiddenTriggerProps, ref: ForwardedRef<HTMLButtonElement>) => {
|
||||
const innerRef = useRef<HTMLButtonElement>(null);
|
||||
const onTriggerStateUpdate = useStrictContext(
|
||||
TriggerStateUpdateContext
|
||||
);
|
||||
export const HiddenTrigger: FC<HiddenTriggerProps> = memo(props => {
|
||||
const { onClick } = props;
|
||||
|
||||
const onTriggerStateUpdateRef = useRef(onTriggerStateUpdate);
|
||||
useLayoutEffect(() => {
|
||||
onTriggerStateUpdateRef.current = onTriggerStateUpdate;
|
||||
}, [onTriggerStateUpdate]);
|
||||
const innerRef = useRef<HTMLButtonElement>(null);
|
||||
const onTriggerStateUpdate = useStrictContext(TriggerStateUpdateContext);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const button = assert(innerRef.current, 'Missing ref');
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const onTriggerStateUpdateRef = useRef(onTriggerStateUpdate);
|
||||
useLayoutEffect(() => {
|
||||
onTriggerStateUpdateRef.current = onTriggerStateUpdate;
|
||||
}, [onTriggerStateUpdate]);
|
||||
|
||||
function update() {
|
||||
onTriggerStateUpdateRef.current({
|
||||
hovered: button.matches(':hover:not(:disabled)'),
|
||||
pressed: button.matches(':active:not(:disabled)'),
|
||||
focused: button.matches('.keyboard-mode :focus'),
|
||||
});
|
||||
}
|
||||
useLayoutEffect(() => {
|
||||
const button = assert(innerRef.current, 'Missing ref');
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
|
||||
function delayedUpdate() {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(update, 1);
|
||||
}
|
||||
|
||||
update();
|
||||
button.addEventListener('pointerenter', update);
|
||||
button.addEventListener('pointerleave', update);
|
||||
button.addEventListener('pointerdown', update);
|
||||
button.addEventListener('pointerup', update);
|
||||
button.addEventListener('focus', update);
|
||||
button.addEventListener('blur', update);
|
||||
// need delay
|
||||
button.addEventListener('keydown', delayedUpdate);
|
||||
button.addEventListener('keyup', delayedUpdate);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
onTriggerStateUpdateRef.current(INITIAL_TRIGGER_STATE);
|
||||
button.removeEventListener('pointerenter', update);
|
||||
button.removeEventListener('pointerleave', update);
|
||||
button.removeEventListener('pointerdown', update);
|
||||
button.removeEventListener('pointerup', update);
|
||||
button.removeEventListener('focus', update);
|
||||
button.removeEventListener('blur', update);
|
||||
// need delay
|
||||
button.removeEventListener('keydown', delayedUpdate);
|
||||
button.removeEventListener('keyup', delayedUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTestOrMockEnvironment()) {
|
||||
assert(
|
||||
computeAccessibleName(assert(innerRef.current)) !== '',
|
||||
`${hiddenTriggerDisplayName} child must have an accessible name`
|
||||
);
|
||||
}
|
||||
function update() {
|
||||
onTriggerStateUpdateRef.current({
|
||||
hovered: button.matches(':hover:not(:disabled)'),
|
||||
pressed: button.matches(':active:not(:disabled)'),
|
||||
focused: button.matches('.keyboard-mode :focus'),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={mergeRefs(ref, innerRef)}
|
||||
{...props}
|
||||
type="button"
|
||||
className={tw('absolute inset-0 z-10 outline-none')}
|
||||
/>
|
||||
function delayedUpdate() {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(update, 1);
|
||||
}
|
||||
|
||||
update();
|
||||
button.addEventListener('pointerenter', update);
|
||||
button.addEventListener('pointerleave', update);
|
||||
button.addEventListener('pointerdown', update);
|
||||
button.addEventListener('pointerup', update);
|
||||
button.addEventListener('focus', update);
|
||||
button.addEventListener('blur', update);
|
||||
// need delay
|
||||
button.addEventListener('keydown', delayedUpdate);
|
||||
button.addEventListener('keyup', delayedUpdate);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
onTriggerStateUpdateRef.current(INITIAL_TRIGGER_STATE);
|
||||
button.removeEventListener('pointerenter', update);
|
||||
button.removeEventListener('pointerleave', update);
|
||||
button.removeEventListener('pointerdown', update);
|
||||
button.removeEventListener('pointerup', update);
|
||||
button.removeEventListener('focus', update);
|
||||
button.removeEventListener('blur', update);
|
||||
// need delay
|
||||
button.removeEventListener('keydown', delayedUpdate);
|
||||
button.removeEventListener('keyup', delayedUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTestOrMockEnvironment()) {
|
||||
assert(
|
||||
computeAccessibleName(assert(innerRef.current)) !== '',
|
||||
'AriaClickable.HiddenTrigger child must have an accessible name'
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
HiddenTrigger.displayName = hiddenTriggerDisplayName;
|
||||
const handleClick = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onClick(event);
|
||||
},
|
||||
[onClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={mergeRefs(props.ref, innerRef)}
|
||||
type="button"
|
||||
aria-label={props.label}
|
||||
aria-labelledby={props.labelledby}
|
||||
onClick={handleClick}
|
||||
className={tw('absolute inset-0 z-10 outline-none')}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
HiddenTrigger.displayName = 'AriaClickable.HiddenTrigger';
|
||||
}
|
||||
|
||||
+143
-44
@@ -12,21 +12,17 @@ import type { AxoSymbol } from './AxoSymbol.dom.tsx';
|
||||
import { FlexWrapDetector } from './_internal/FlexWrapDetector.dom.tsx';
|
||||
import { AxoTheme } from './AxoTheme.dom.tsx';
|
||||
|
||||
const Namespace = 'AxoAlertDialog';
|
||||
|
||||
const { useContentEscapeBehavior } = AxoBaseDialog;
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
* A modal dialog that interrupts the user with important content and expects a
|
||||
* response. Unlike `AxoDialog`, it has no header or close button — the user
|
||||
* must explicitly choose an action.
|
||||
*
|
||||
* @example Anatomy
|
||||
* ```tsx
|
||||
* <AxoAlertDialog.Root>
|
||||
* <AxoAlertDialog.Trigger>
|
||||
* </AxoAlertDialog.Trigger>
|
||||
* <AxoAlertDialog.Trigger />
|
||||
* <AxoAlertDialog.Content>
|
||||
* <AxoAlertDialog.Body>
|
||||
* <AxoAlertDialog.Title />
|
||||
@@ -39,15 +35,42 @@ const { useContentEscapeBehavior } = AxoBaseDialog;
|
||||
* </AxoAlertDialog.Content>
|
||||
* </AxoAlertDialog.Root>
|
||||
* ```
|
||||
*
|
||||
* @see {@link https://www.radix-ui.com/primitives/docs/components/alert-dialig | Alert Dialog - Radix Docs}
|
||||
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/ | Alert and Message Dialogs Pattern - ARIA Authoring Practices Guide}
|
||||
* @see {@link https://w3c.github.io/aria/#alertdialog | `alertdialog` role - WAI-ARIA 1.3}
|
||||
*/
|
||||
export namespace AxoAlertDialog {
|
||||
/**
|
||||
* Component: <AxoAlertDialog.Root>
|
||||
* --------------------------------
|
||||
* <AxoAlertDialog.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type RootProps = AxoBaseDialog.RootProps;
|
||||
|
||||
/**
|
||||
* Contains all the parts of an alert dialog.
|
||||
*
|
||||
* @example Discard confirmation
|
||||
* ```tsx
|
||||
* <AxoAlertDialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
* <AxoAlertDialog.Content escape="cancel-is-noop">
|
||||
* <AxoAlertDialog.Body>
|
||||
* <AxoAlertDialog.Title>Discard draft?</AxoAlertDialog.Title>
|
||||
* <AxoAlertDialog.Description>
|
||||
* Your message will be deleted and cannot be recovered.
|
||||
* </AxoAlertDialog.Description>
|
||||
* </AxoAlertDialog.Body>
|
||||
* <AxoAlertDialog.Footer>
|
||||
* <AxoAlertDialog.Cancel>Cancel</AxoAlertDialog.Cancel>
|
||||
* <AxoAlertDialog.Action variant="destructive" onClick={onDiscard}>
|
||||
* Discard
|
||||
* </AxoAlertDialog.Action>
|
||||
* </AxoAlertDialog.Footer>
|
||||
* </AxoAlertDialog.Content>
|
||||
* </AxoAlertDialog.Root>
|
||||
* ```
|
||||
*/
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
return (
|
||||
<AlertDialog.Root open={props.open} onOpenChange={props.onOpenChange}>
|
||||
@@ -56,32 +79,50 @@ export namespace AxoAlertDialog {
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = `${Namespace}.Root`;
|
||||
Root.displayName = 'AxoAlertDialog.Root';
|
||||
|
||||
/**
|
||||
* Component: <AxoAlertDialog.Trigger>
|
||||
* --------------------------------
|
||||
* <AxoAlertDialog.Trigger>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type TriggerProps = AxoBaseDialog.TriggerProps;
|
||||
|
||||
/**
|
||||
* A button that opens the dialog.
|
||||
*/
|
||||
export const Trigger: FC<TriggerProps> = memo(props => {
|
||||
return <AlertDialog.Trigger asChild>{props.children}</AlertDialog.Trigger>;
|
||||
});
|
||||
|
||||
Trigger.displayName = `${Namespace}.Trigger`;
|
||||
Trigger.displayName = 'AxoAlertDialog.Trigger';
|
||||
|
||||
/**
|
||||
* Component: <AxoAlertDialog.Content>
|
||||
* --------------------------------
|
||||
* <AxoAlertDialog.Content>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* How dangerous the cancel action is considered.
|
||||
* - `cancel-is-noop`: Canceling is safe — pressing Escape or clicking outside closes the dialog.
|
||||
* - `cancel-is-destructive`: Canceling would lose user state — pressing Escape or clicking outside is disabled.
|
||||
*/
|
||||
export type ContentEscape = AxoBaseDialog.ContentEscape;
|
||||
|
||||
export type ContentProps = Readonly<{
|
||||
/**
|
||||
* What happens when the user presses `Escape` or clicks outside.
|
||||
*/
|
||||
escape: ContentEscape;
|
||||
/**
|
||||
* Should be `Body` and `Footer` elements.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Contains content to be rendered when the dialog is open.
|
||||
*/
|
||||
export const Content: FC<ContentProps> = memo(props => {
|
||||
const handleContentEscapeEvent = useContentEscapeBehavior(props.escape);
|
||||
return (
|
||||
@@ -104,17 +145,24 @@ export namespace AxoAlertDialog {
|
||||
);
|
||||
});
|
||||
|
||||
Content.displayName = `${Namespace}.Content`;
|
||||
Content.displayName = 'AxoAlertDialog.Content';
|
||||
|
||||
/**
|
||||
* Component: <AxoAlertDialog.Body>
|
||||
* ---------------------------------
|
||||
* <AxoAlertDialog.Body>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type BodyProps = Readonly<{
|
||||
/**
|
||||
* The scrollable body content.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Scrollable content area before `Footer`.
|
||||
* Automatically shows scroll hint and a thin scrollbar.
|
||||
*/
|
||||
export const Body: FC<BodyProps> = memo(props => {
|
||||
return (
|
||||
<AxoScrollArea.Root maxHeight={440} scrollbarWidth="none">
|
||||
@@ -130,17 +178,23 @@ export namespace AxoAlertDialog {
|
||||
);
|
||||
});
|
||||
|
||||
Body.displayName = `${Namespace}.Body`;
|
||||
Body.displayName = 'AxoAlertDialog.Body';
|
||||
|
||||
/**
|
||||
* Component: <AxoAlertDialog.Footer>
|
||||
* ---------------------------------
|
||||
* <AxoAlertDialog.Footer>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type FooterProps = Readonly<{
|
||||
/**
|
||||
* Should be a `Cancel` and one or more `Action` elements.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A row of buttons at the bottom of the dialog.
|
||||
*/
|
||||
export const Footer: FC<FooterProps> = memo(props => {
|
||||
return (
|
||||
<FlexWrapDetector>
|
||||
@@ -162,18 +216,28 @@ export namespace AxoAlertDialog {
|
||||
);
|
||||
});
|
||||
|
||||
Footer.displayName = `${Namespace}.Footer`;
|
||||
Footer.displayName = 'AxoAlertDialog.Footer';
|
||||
|
||||
/**
|
||||
* Component: <AxoAlertDialog.Title>
|
||||
* ---------------------------------
|
||||
* <AxoAlertDialog.Title>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type TitleProps = Readonly<{
|
||||
/**
|
||||
* There must always be a title for the dialog, but if you don't want it to
|
||||
* be visually displayed you can pass `screenReaderOnly: true`
|
||||
*/
|
||||
screenReaderOnly?: boolean;
|
||||
/**
|
||||
* The title text.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* An accessible name to be announced when the dialog is opened.
|
||||
*/
|
||||
export const Title: FC<TitleProps> = memo(props => {
|
||||
return (
|
||||
<AlertDialog.Title
|
||||
@@ -187,17 +251,23 @@ export namespace AxoAlertDialog {
|
||||
);
|
||||
});
|
||||
|
||||
Title.displayName = `${Namespace}.Title`;
|
||||
Title.displayName = 'AxoAlertDialog.Title';
|
||||
|
||||
/**
|
||||
* Component: <AxoAlertDialog.Description>
|
||||
* ---------------------------------------
|
||||
* <AxoAlertDialog.Description>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type DescriptionProps = Readonly<{
|
||||
/**
|
||||
* The description text.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* An optional accessible description to be announced when the dialog is opened.
|
||||
*/
|
||||
export const Description: FC<DescriptionProps> = memo(props => {
|
||||
return (
|
||||
<AlertDialog.Description
|
||||
@@ -208,19 +278,28 @@ export namespace AxoAlertDialog {
|
||||
);
|
||||
});
|
||||
|
||||
Description.displayName = `${Namespace}.Description`;
|
||||
Description.displayName = 'AxoAlertDialog.Description';
|
||||
|
||||
/**
|
||||
* Component: <AxoAlertDialog.Cancel>
|
||||
* ----------------------------------
|
||||
* <AxoAlertDialog.Cancel>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type CancelProps = Readonly<{
|
||||
/**
|
||||
* The button label.
|
||||
*/
|
||||
children: ReactNode;
|
||||
/**
|
||||
* When `true`, prevents the user from dismissing via this button.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
focusableWhenDisabled?: boolean;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A button that dismisses the dialog without taking the primary action.
|
||||
* Automatically closes the dialog — no `onClick` needed.
|
||||
*/
|
||||
export const Cancel: FC<CancelProps> = memo(props => {
|
||||
return (
|
||||
<AlertDialog.Cancel asChild>
|
||||
@@ -229,7 +308,6 @@ export namespace AxoAlertDialog {
|
||||
size="md"
|
||||
width="grow"
|
||||
disabled={props.disabled}
|
||||
focusableWhenDisabled={props.focusableWhenDisabled}
|
||||
>
|
||||
{props.children}
|
||||
</AxoButton.Root>
|
||||
@@ -237,13 +315,16 @@ export namespace AxoAlertDialog {
|
||||
);
|
||||
});
|
||||
|
||||
Cancel.displayName = `${Namespace}.Cancel`;
|
||||
Cancel.displayName = 'AxoAlertDialog.Cancel';
|
||||
|
||||
/**
|
||||
* Component: <AxoAlertDialog.Action>
|
||||
* ----------------------------------
|
||||
* <AxoAlertDialog.Action>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Visual style of an action button.
|
||||
*/
|
||||
export type ActionVariant =
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
@@ -251,29 +332,47 @@ export namespace AxoAlertDialog {
|
||||
| 'subtle-destructive';
|
||||
|
||||
export type ActionProps = Readonly<{
|
||||
/**
|
||||
* Visual style of the button.
|
||||
*/
|
||||
variant: ActionVariant;
|
||||
/**
|
||||
* Optional leading icon.
|
||||
*/
|
||||
symbol?: AxoSymbol.InlineGlyphName;
|
||||
/**
|
||||
* When `true`, shows a forward arrow on the trailing side.
|
||||
*/
|
||||
arrow?: boolean;
|
||||
/**
|
||||
* Called when the button is clicked.
|
||||
*/
|
||||
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
/**
|
||||
* When `true`, prevents interaction.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
focusableWhenDisabled?: boolean;
|
||||
/**
|
||||
* The button label.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A button that confirms or takes a named action. Requires an explicit
|
||||
* `onClick` — unlike `Cancel`, it does not auto-close the dialog.
|
||||
*/
|
||||
export const Action: FC<ActionProps> = memo(props => {
|
||||
return (
|
||||
<AlertDialog.Action
|
||||
asChild
|
||||
onClick={props.onClick}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<AlertDialog.Action asChild>
|
||||
<AxoButton.Root
|
||||
variant={props.variant}
|
||||
symbol={props.symbol}
|
||||
arrow={props.arrow ? 'next' : null}
|
||||
size="md"
|
||||
width="grow"
|
||||
focusableWhenDisabled
|
||||
onClick={props.onClick}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
{props.children}
|
||||
</AxoButton.Root>
|
||||
@@ -281,5 +380,5 @@ export namespace AxoAlertDialog {
|
||||
);
|
||||
});
|
||||
|
||||
Action.displayName = `${Namespace}.Action`;
|
||||
Action.displayName = 'AxoAlertDialog.Action';
|
||||
}
|
||||
|
||||
+315
-107
@@ -5,12 +5,11 @@ import type {
|
||||
CSSProperties,
|
||||
FC,
|
||||
ImgHTMLAttributes,
|
||||
MouseEventHandler,
|
||||
MouseEvent,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { AxoSymbol } from './AxoSymbol.dom.tsx';
|
||||
import type { TailwindStyles } from './tw.dom.tsx';
|
||||
import { tw } from './tw.dom.tsx';
|
||||
import {
|
||||
createStrictContext,
|
||||
@@ -18,27 +17,33 @@ import {
|
||||
} from './_internal/StrictContext.dom.tsx';
|
||||
import { assert } from './_internal/assert.std.tsx';
|
||||
import { AxoTokens } from './AxoTokens.std.ts';
|
||||
|
||||
const Namespace = 'AxoAvatar';
|
||||
import { variants } from './_internal/variants.dom.tsx';
|
||||
|
||||
/**
|
||||
* A circular avatar for displaying a user or group's profile image, initials,
|
||||
* gradient, or icon — with optional badge and unread-story ring overlays.
|
||||
*
|
||||
* @example Anatomy
|
||||
* ```tsx
|
||||
* <AxoAvatar.Root>
|
||||
* <AxoAvatar.Content>
|
||||
* {
|
||||
* <AxoAvatar.Icon/> ||
|
||||
* <AxoAvatar.Initials/> ||
|
||||
* <AxoAvatar.Gradient/> ||
|
||||
* <AxoAvatar.Image/>
|
||||
* }
|
||||
* <AxoAvatar.ClickToView/>
|
||||
* {/* One of: *\/}
|
||||
* <AxoAvatar.Image />
|
||||
* <AxoAvatar.Initials />
|
||||
* <AxoAvatar.Gradient />
|
||||
* <AxoAvatar.Icon />
|
||||
* <AxoAvatar.Preset />
|
||||
* {/* Optional overlay: *\/}
|
||||
* <AxoAvatar.ClickToView />
|
||||
* </AxoAvatar.Content>
|
||||
* <AxoAvatar.Badge/>
|
||||
* </AxoAlertDialog.Root>
|
||||
* <AxoAvatar.Badge />
|
||||
* </AxoAvatar.Root>
|
||||
* ```
|
||||
*/
|
||||
export namespace AxoAvatar {
|
||||
/**
|
||||
* Width and height of the avatar in pixels.
|
||||
*/
|
||||
export type Size =
|
||||
| 20
|
||||
| 24
|
||||
@@ -55,9 +60,10 @@ export namespace AxoAvatar {
|
||||
| 96
|
||||
| 216;
|
||||
|
||||
const SizeContext = createStrictContext<Size>(`${Namespace}.Root`);
|
||||
/** @internal */
|
||||
const SizeContext = createStrictContext<Size>('AxoAvatar.Root');
|
||||
|
||||
const RootSizes: Record<Size, TailwindStyles> = {
|
||||
const RootSizes = variants<Size>('AxoAvatar.Size', {
|
||||
20: tw('size-[20px]'),
|
||||
24: tw('size-[24px]'),
|
||||
28: tw('size-[28px]'),
|
||||
@@ -72,9 +78,9 @@ export namespace AxoAvatar {
|
||||
80: tw('size-[80px]'),
|
||||
96: tw('size-[96px]'),
|
||||
216: tw('size-[216px]'),
|
||||
};
|
||||
});
|
||||
|
||||
const RingSizes: Record<Size, TailwindStyles | null> = {
|
||||
const RingSizes = variants<Size>('AxoAvatar.Size', {
|
||||
20: tw('border p-[1.5px]'),
|
||||
24: tw('border p-[1.5px]'),
|
||||
28: tw('border-[1.5px] p-[2px]'),
|
||||
@@ -89,33 +95,61 @@ export namespace AxoAvatar {
|
||||
80: tw('border-[2.5px] p-[3.5px]'),
|
||||
96: tw('border-[3px] p-[4px]'),
|
||||
216: tw('border-4 p-[6px]'),
|
||||
};
|
||||
});
|
||||
|
||||
/** @testexport */
|
||||
export function _getAllSizes(): ReadonlyArray<Size> {
|
||||
return Object.keys(RootSizes).map(size => Number(size) as Size);
|
||||
return RootSizes.keys().map(size => Number(size) as Size);
|
||||
}
|
||||
|
||||
const DefaultColor = tw('bg-fill-secondary text-label-primary');
|
||||
|
||||
/**
|
||||
* Component: <AxoAvatar.Root>
|
||||
* ---------------------------
|
||||
* <AxoAvatar.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type RootProps = Readonly<{
|
||||
/**
|
||||
* Width and height of the avatar in pixels.
|
||||
*/
|
||||
size: Size;
|
||||
/**
|
||||
* Story ring shown around the avatar.
|
||||
* - `unread`: Colored ring indicating an unread story.
|
||||
* - `read`: Dimmed ring indicating a viewed story.
|
||||
* - `null`: No ring.
|
||||
*/
|
||||
ring?: 'unread' | 'read' | null;
|
||||
/**
|
||||
* Should be a `Content` element, optionally followed by a `Badge`.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Contains all the parts of an avatar.
|
||||
*
|
||||
* @example Contact avatar with initials and badge
|
||||
* ```tsx
|
||||
* <AxoAvatar.Root size={40} ring={hasUnreadStory ? 'unread' : null}>
|
||||
* <AxoAvatar.Content label={contact.name}>
|
||||
* <AxoAvatar.Initials initials={contact.initials} color={contact.color} />
|
||||
* </AxoAvatar.Content>
|
||||
* {contact.badge && (
|
||||
* <AxoAvatar.Badge label={contact.badge.name} svgs={contact.badge.svgs} />
|
||||
* )}
|
||||
* </AxoAvatar.Root>
|
||||
* ```
|
||||
*/
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
return (
|
||||
<SizeContext.Provider value={props.size}>
|
||||
<div
|
||||
className={tw(
|
||||
'relative shrink-0 rounded-full contain-layout select-none',
|
||||
RootSizes[props.size],
|
||||
props.ring != null && RingSizes[props.size],
|
||||
RootSizes.get(props.size),
|
||||
props.ring != null && RingSizes.get(props.size),
|
||||
props.ring === 'unread' && 'border-border-selected',
|
||||
props.ring === 'read' && 'border-label-secondary'
|
||||
)}
|
||||
@@ -126,59 +160,127 @@ export namespace AxoAvatar {
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = `${Namespace}.Root`;
|
||||
Root.displayName = 'AxoAvatar.Root';
|
||||
|
||||
/**
|
||||
* Component: <AxoAvatar.Content>
|
||||
* ------------------------------
|
||||
* <AxoAvatar.Content>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ContentProps = Readonly<{
|
||||
/**
|
||||
* Accessible label for the avatar.
|
||||
*
|
||||
* Pass `null` when the avatar is purely decorative and a nearby element
|
||||
* already identifies the contact.
|
||||
*/
|
||||
label: string | null;
|
||||
onClick?: MouseEventHandler<HTMLButtonElement> | null;
|
||||
/**
|
||||
* When provided, renders the content as a `<button>` with this click handler.
|
||||
* Without it, renders as a `<div role="img">`.
|
||||
*/
|
||||
onClick?: ((event: MouseEvent<HTMLButtonElement>) => void) | null;
|
||||
/**
|
||||
* The visual content of the avatar, should be one of:
|
||||
*
|
||||
* - `Image`
|
||||
* - `Initials`
|
||||
* - `Gradient`
|
||||
* - `Icon`
|
||||
* - `Preset`
|
||||
*
|
||||
* Optionally followed by `ClickToView`.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
export const Content: FC<ContentProps> = memo(props => {
|
||||
const ariaLabel = props.label ?? undefined;
|
||||
const baseClassName = tw('relative size-full rounded-full contain-strict');
|
||||
|
||||
let result: ReactNode;
|
||||
if (props.onClick != null) {
|
||||
result = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
className={tw(
|
||||
baseClassName,
|
||||
'outline-none keyboard-mode:focus:outline-focus-ring'
|
||||
)}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
result = (
|
||||
<div role="img" aria-label={ariaLabel} className={baseClassName}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
Content.displayName = `${Namespace}.Content`;
|
||||
const baseContentStyles = tw(
|
||||
'relative size-full rounded-full contain-strict'
|
||||
);
|
||||
|
||||
/**
|
||||
* Component: <AxoAvatar.Icon>
|
||||
* ---------------------------
|
||||
* The content of the avatar.
|
||||
*
|
||||
* Renders as a button when `onClick` is provided, otherwise as an image.
|
||||
*/
|
||||
export const Content: FC<ContentProps> = memo(props => {
|
||||
if (props.onClick != null) {
|
||||
return (
|
||||
<ContentButton label={props.label} onClick={props.onClick}>
|
||||
{props.children}
|
||||
</ContentButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label={props.label ?? undefined}
|
||||
className={baseContentStyles}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Content.displayName = 'AxoAvatar.Content';
|
||||
|
||||
/**
|
||||
* <AxoAvatar.ContentButton>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/** @internal */
|
||||
type ContentButtonProps = Readonly<{
|
||||
label: string | null;
|
||||
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/** @internal */
|
||||
const ContentButton: FC<ContentButtonProps> = memo(props => {
|
||||
const { onClick } = props;
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onClick(event);
|
||||
},
|
||||
[onClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={props.label ?? undefined}
|
||||
className={tw(
|
||||
baseContentStyles,
|
||||
'outline-none keyboard-mode:focus:outline-focus-ring'
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
ContentButton.displayName = 'AxoAvatar.ContentButton';
|
||||
|
||||
/**
|
||||
* <AxoAvatar.Icon>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type IconProps = Readonly<{
|
||||
/**
|
||||
* The icon to display. Sized proportionally to the avatar.
|
||||
*/
|
||||
symbol: AxoSymbol.IconName;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Displays a centered icon on a secondary fill background.
|
||||
*/
|
||||
export const Icon: FC<IconProps> = memo(props => {
|
||||
const size = useStrictContext(SizeContext);
|
||||
return (
|
||||
@@ -194,22 +296,43 @@ export namespace AxoAvatar {
|
||||
);
|
||||
});
|
||||
|
||||
Icon.displayName = `${Namespace}.Icon`;
|
||||
Icon.displayName = 'AxoAvatar.Icon';
|
||||
|
||||
/**
|
||||
* Component: <AxoAvatar.Image>
|
||||
* ----------------------------
|
||||
* <AxoAvatar.Image>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ImageProps = Readonly<{
|
||||
/**
|
||||
* URL of the avatar image.
|
||||
*/
|
||||
src: string;
|
||||
/**
|
||||
* Intrinsic width of the source image in pixels.
|
||||
*/
|
||||
srcWidth: number;
|
||||
/**
|
||||
* Intrinsic height of the source image in pixels.
|
||||
*/
|
||||
srcHeight: number;
|
||||
/**
|
||||
* When `true`, applies a blur and slight zoom to the image.
|
||||
*/
|
||||
blur: boolean;
|
||||
/**
|
||||
* Icon to show if the image fails to load.
|
||||
*/
|
||||
fallbackIcon: AxoSymbol.IconName;
|
||||
/**
|
||||
* Color theme for the fallback icon background.
|
||||
*/
|
||||
fallbackColor: AxoTokens.Avatar.ColorName;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Lazily loads a profile image. Falls back to `Icon` on load error.
|
||||
*/
|
||||
export const Image: FC<ImageProps> = memo(props => {
|
||||
const { src } = props;
|
||||
|
||||
@@ -263,16 +386,23 @@ export namespace AxoAvatar {
|
||||
);
|
||||
});
|
||||
|
||||
Image.displayName = `${Namespace}.Image`;
|
||||
Image.displayName = 'AxoAvatar.Image';
|
||||
|
||||
/**
|
||||
* Component: <AxoAvatar.PresetImage>
|
||||
* <AxoAvatar.Preset>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type PresetProps = Readonly<{
|
||||
/**
|
||||
* One of the pre-designed avatar presets (e.g. `"cat"`, `"abstract_01"`).
|
||||
*/
|
||||
preset: AxoTokens.Avatar.PresetName;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Displays one of the built-in preset avatar illustrations.
|
||||
*/
|
||||
export const Preset: FC<PresetProps> = memo(props => {
|
||||
const { preset } = props;
|
||||
|
||||
@@ -298,25 +428,34 @@ export namespace AxoAvatar {
|
||||
);
|
||||
});
|
||||
|
||||
Preset.displayName = `${Namespace}.Preset`;
|
||||
Preset.displayName = 'AxoAvatar.Preset';
|
||||
|
||||
/**
|
||||
* Component: <AxoAvatar.ClickToView>
|
||||
* ----------------------------------
|
||||
* <AxoAvatar.ClickToView>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/** @testexport */
|
||||
export const MIN_CLICK_TO_VIEW_SIZE = 80;
|
||||
|
||||
export type ClickToViewProps = Readonly<{
|
||||
/**
|
||||
* Accessible label and visible text for the overlay (e.g. `"View"`).
|
||||
*/
|
||||
label: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A semi-transparent overlay with a tap icon and label, used on profile
|
||||
* photos that open a full-screen viewer when clicked.
|
||||
* Only valid at size ≥ `MIN_CLICK_TO_VIEW_SIZE` (80px).
|
||||
*/
|
||||
export const ClickToView: FC<ClickToViewProps> = memo(props => {
|
||||
const size = useStrictContext(SizeContext);
|
||||
|
||||
assert(
|
||||
size >= MIN_CLICK_TO_VIEW_SIZE,
|
||||
`Cannot render ${Namespace}.ClickToView at a size smaller than ${MIN_CLICK_TO_VIEW_SIZE}`
|
||||
`Cannot render <AxoAvatar.ClickToView> at a size smaller than ${MIN_CLICK_TO_VIEW_SIZE}`
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -335,18 +474,27 @@ export namespace AxoAvatar {
|
||||
);
|
||||
});
|
||||
|
||||
ClickToView.displayName = `${Namespace}.ClickToView`;
|
||||
ClickToView.displayName = 'AxoAvatar.ClickToView';
|
||||
|
||||
/**
|
||||
* Component: <AxoAvatar.Initials>
|
||||
* -------------------------------
|
||||
* <AxoAvatar.Initials>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type InitialsProps = Readonly<{
|
||||
/**
|
||||
* 1–2 character string to display (e.g. `"JK"`).
|
||||
*/
|
||||
initials: string;
|
||||
/**
|
||||
* Color theme for the background and text.
|
||||
*/
|
||||
color: AxoTokens.Avatar.ColorName;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Renders initials as an SVG on a colored background.
|
||||
*/
|
||||
export const Initials: FC<InitialsProps> = memo(props => {
|
||||
const style = useMemo((): CSSProperties => {
|
||||
const color = AxoTokens.Avatar.getColorValues(props.color);
|
||||
@@ -374,17 +522,24 @@ export namespace AxoAvatar {
|
||||
);
|
||||
});
|
||||
|
||||
Initials.displayName = `${Namespace}.Initials`;
|
||||
Initials.displayName = 'AxoAvatar.Initials';
|
||||
|
||||
/**
|
||||
* Component: <AxoAvatar.Gradient>
|
||||
* -------------------------------
|
||||
* <AxoAvatar.Gradient>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type GradientProps = Readonly<{
|
||||
/**
|
||||
* A numeric hash (typically derived from a conversation or user ID) that
|
||||
* deterministically selects one of the available gradient themes.
|
||||
*/
|
||||
identifierHash: number;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Fills the avatar with a gradient background chosen by `identifierHash`.
|
||||
*/
|
||||
export const Gradient: FC<GradientProps> = memo(props => {
|
||||
const { identifierHash } = props;
|
||||
const style = useMemo((): CSSProperties => {
|
||||
@@ -399,18 +554,25 @@ export namespace AxoAvatar {
|
||||
);
|
||||
});
|
||||
|
||||
Gradient.displayName = `${Namespace}.Gradient`;
|
||||
Gradient.displayName = 'AxoAvatar.Gradient';
|
||||
|
||||
/**
|
||||
* Component: <AxoAvatar.Badge>
|
||||
* ----------------------------
|
||||
* <AxoAvatar.Badge>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Paths to an SVG badge image for light and dark color schemes.
|
||||
*/
|
||||
export type BadgeSvg = Readonly<{
|
||||
light: string;
|
||||
dark: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Badge SVGs at each of the three rendered sizes (16, 24, and 36px).
|
||||
* The correct size is chosen automatically based on the avatar size.
|
||||
*/
|
||||
export type BadgeSvgs = Readonly<{
|
||||
16: BadgeSvg;
|
||||
24: BadgeSvg;
|
||||
@@ -418,12 +580,22 @@ export namespace AxoAvatar {
|
||||
}>;
|
||||
|
||||
export type BadgeProps = Readonly<{
|
||||
/**
|
||||
* Accessible label for the badge (e.g. `"Signal Planet"`).
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* SVG paths for all badge sizes and color schemes.
|
||||
*/
|
||||
svgs: BadgeSvgs;
|
||||
onClick?: MouseEventHandler<HTMLButtonElement> | null;
|
||||
/**
|
||||
* When provided, renders the badge as a clickable `<button>`.
|
||||
*/
|
||||
onClick?: ((event: MouseEvent<HTMLButtonElement>) => void) | null;
|
||||
}>;
|
||||
|
||||
const BadgeSvgSizes: Record<Size, keyof BadgeSvgs | null> = {
|
||||
type BadgeSvgSize = keyof BadgeSvgs | null;
|
||||
const BadgeSvgSizes = variants<Size, BadgeSvgSize>('AxoAvatar.Size', {
|
||||
20: null,
|
||||
24: null,
|
||||
28: 16,
|
||||
@@ -438,14 +610,26 @@ export namespace AxoAvatar {
|
||||
80: 36,
|
||||
96: 36,
|
||||
216: 36,
|
||||
};
|
||||
});
|
||||
|
||||
const baseBadgeStyles = tw(
|
||||
'absolute rounded-full',
|
||||
// Proportionately sized & positioned based on the size of the avatar
|
||||
'-inset-e-[calc(2.75px-3%)] -bottom-[calc(6.25px-1%)] size-[calc(5px+37.5%)]'
|
||||
);
|
||||
|
||||
/**
|
||||
* A donor badge overlaid on the bottom-end corner of the avatar.
|
||||
* Automatically picks the right size for the current avatar size.
|
||||
*
|
||||
* Note: Not rendered at sizes 20 or 24 (too small).
|
||||
*/
|
||||
export const Badge: FC<BadgeProps> = memo(props => {
|
||||
const { svgs } = props;
|
||||
const avatarSize = useStrictContext(SizeContext);
|
||||
|
||||
const badge = useMemo(() => {
|
||||
const badgeSize = BadgeSvgSizes[avatarSize];
|
||||
const badgeSize = BadgeSvgSizes.get(avatarSize);
|
||||
if (badgeSize == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -489,37 +673,61 @@ export namespace AxoAvatar {
|
||||
</>
|
||||
);
|
||||
|
||||
const baseClassName = tw(
|
||||
'absolute rounded-full',
|
||||
// Proportionately sized & positioned based on the size of the avatar
|
||||
'-inset-e-[calc(2.75px-3%)] -bottom-[calc(6.25px-1%)] size-[calc(5px+37.5%)]'
|
||||
);
|
||||
|
||||
let result: ReactNode;
|
||||
if (props.onClick != null) {
|
||||
result = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={props.label}
|
||||
onClick={props.onClick}
|
||||
className={tw(
|
||||
baseClassName,
|
||||
'outline-focus-ring-inset outline-none keyboard-mode:focus:outline-focus-ring'
|
||||
)}
|
||||
>
|
||||
return (
|
||||
<BadgeButton label={props.label} onClick={props.onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
result = (
|
||||
<div className={tw(baseClassName, 'pointer-events-none')}>
|
||||
{children}
|
||||
</div>
|
||||
</BadgeButton>
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
return (
|
||||
<div className={tw(baseBadgeStyles, 'pointer-events-none')}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Badge.displayName = `${Namespace}.Badge`;
|
||||
Badge.displayName = 'AxoAvatar.Badge';
|
||||
|
||||
/**
|
||||
* <AxoAvatar.BadgeButton>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/** @internal */
|
||||
type BadgeButtonProps = Readonly<{
|
||||
label: string;
|
||||
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/** @internal */
|
||||
const BadgeButton: FC<BadgeButtonProps> = memo(props => {
|
||||
const { onClick } = props;
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onClick(event);
|
||||
},
|
||||
[onClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={props.label}
|
||||
onClick={handleClick}
|
||||
className={tw(
|
||||
baseBadgeStyles,
|
||||
'outline-focus-ring-inset outline-none keyboard-mode:focus:outline-focus-ring'
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
BadgeButton.displayName = 'AxoAvatar.BadgeButton';
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export default {
|
||||
} satisfies Meta;
|
||||
|
||||
export function All(): JSX.Element {
|
||||
const values: ReadonlyArray<ExperimentalAxoBadge.BadgeValue> = [
|
||||
const values: ReadonlyArray<ExperimentalAxoBadge.Value> = [
|
||||
-1,
|
||||
0,
|
||||
1,
|
||||
@@ -32,7 +32,7 @@ export function All(): JSX.Element {
|
||||
})}
|
||||
</thead>
|
||||
<tbody>
|
||||
{ExperimentalAxoBadge._getAllBadgeSizes().map(size => {
|
||||
{ExperimentalAxoBadge._getAllSizes().map(size => {
|
||||
return (
|
||||
<tr key={size}>
|
||||
<th>{size}</th>
|
||||
@@ -44,7 +44,7 @@ export function All(): JSX.Element {
|
||||
value={value}
|
||||
max={99}
|
||||
maxDisplay="99+"
|
||||
aria-label={null}
|
||||
label={null}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
|
||||
+62
-39
@@ -3,11 +3,9 @@
|
||||
import type { FC } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { AxoSymbol } from './AxoSymbol.dom.tsx';
|
||||
import type { TailwindStyles } from './tw.dom.tsx';
|
||||
import { tw } from './tw.dom.tsx';
|
||||
import { unreachable } from './_internal/assert.std.tsx';
|
||||
|
||||
const Namespace = 'AxoBadge';
|
||||
import { variants } from './_internal/variants.dom.tsx';
|
||||
|
||||
/**
|
||||
* @example Anatomy
|
||||
@@ -24,8 +22,21 @@ const Namespace = 'AxoBadge';
|
||||
* ````
|
||||
*/
|
||||
export namespace ExperimentalAxoBadge {
|
||||
export type BadgeSize = 'sm' | 'md' | 'lg';
|
||||
export type BadgeValue = number | 'mention' | 'unread';
|
||||
/**
|
||||
* Visual size of the badge.
|
||||
* - `sm`: 14px height
|
||||
* - `md`: 16px height
|
||||
* - `lg`: 18px height
|
||||
*/
|
||||
export type Size = 'sm' | 'md' | 'lg';
|
||||
|
||||
/**
|
||||
* What the badge represents.
|
||||
* - `number`: A numeric count, displayed with optional overflow formatting.
|
||||
* - `'mention'`: Shows an `@`-sign icon.
|
||||
* - `'unread'`: A dot with no text content.
|
||||
*/
|
||||
export type Value = number | 'mention' | 'unread';
|
||||
|
||||
const baseStyles = tw(
|
||||
'flex size-fit items-center justify-center-safe overflow-clip',
|
||||
@@ -35,28 +46,21 @@ export namespace ExperimentalAxoBadge {
|
||||
'select-none'
|
||||
);
|
||||
|
||||
type BadgeConfig = Readonly<{
|
||||
rootStyles: TailwindStyles;
|
||||
countStyles: TailwindStyles;
|
||||
}>;
|
||||
const Sizes = variants<Size>('AxoBadge.Size', {
|
||||
sm: tw(baseStyles, 'min-h-3.5 min-w-3.5 text-[8px] leading-3.5'),
|
||||
md: tw(baseStyles, 'min-h-4 min-w-4 text-[11px] leading-4'),
|
||||
lg: tw(baseStyles, 'min-h-4.5 min-w-4.5 text-[11px] leading-4.5'),
|
||||
});
|
||||
|
||||
const BadgeSizes: Record<BadgeSize, BadgeConfig> = {
|
||||
sm: {
|
||||
rootStyles: tw(baseStyles, 'min-h-3.5 min-w-3.5 text-[8px] leading-3.5'),
|
||||
countStyles: tw('px-[3px]'),
|
||||
},
|
||||
md: {
|
||||
rootStyles: tw(baseStyles, 'min-h-4 min-w-4 text-[11px] leading-4'),
|
||||
countStyles: tw('px-[4px]'),
|
||||
},
|
||||
lg: {
|
||||
rootStyles: tw(baseStyles, 'min-h-4.5 min-w-4.5 text-[11px] leading-4.5'),
|
||||
countStyles: tw('px-[5px]'),
|
||||
},
|
||||
};
|
||||
const CountSizes = variants<Size>('AxoBadge.Size', {
|
||||
sm: tw('px-[3px]'),
|
||||
md: tw('px-[4px]'),
|
||||
lg: tw('px-[5px]'),
|
||||
});
|
||||
|
||||
export function _getAllBadgeSizes(): ReadonlyArray<BadgeSize> {
|
||||
return Object.keys(BadgeSizes) as Array<BadgeSize>;
|
||||
/** @testexport */
|
||||
export function _getAllSizes(): ReadonlyArray<Size> {
|
||||
return Sizes.keys();
|
||||
}
|
||||
|
||||
let cachedNumberFormat: Intl.NumberFormat;
|
||||
@@ -74,21 +78,43 @@ export namespace ExperimentalAxoBadge {
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: <AxoBadge.Root>
|
||||
* --------------------------
|
||||
* <AxoBadge.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type RootProps = Readonly<{
|
||||
size: BadgeSize;
|
||||
value: BadgeValue;
|
||||
/** Visual size of the badge. */
|
||||
size: Size;
|
||||
/** What the badge represents. */
|
||||
value: Value;
|
||||
/** When `value` is a number, values above this are replaced with `maxDisplay`. */
|
||||
max: number;
|
||||
/** The string shown when the numeric `value` exceeds `max` (e.g. `"999+"`). */
|
||||
maxDisplay: string;
|
||||
'aria-label': string | null;
|
||||
/** Accessible label for screen readers. Pass `null` if the badge is purely decorative. */
|
||||
label: string | null;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Renders a colored pill badge.
|
||||
*
|
||||
* @example Count with overflow
|
||||
* ```tsx
|
||||
* <ExperimentalAxoBadge.Root size="md" value={42} max={99} maxDisplay="99+" label="42 unread messages" />
|
||||
* ```
|
||||
*
|
||||
* @example Mention
|
||||
* ```tsx
|
||||
* <ExperimentalAxoBadge.Root size="md" value="mention" max={0} maxDisplay="" label="You were mentioned" />
|
||||
* ```
|
||||
*
|
||||
* @example Unread dot
|
||||
* ```tsx
|
||||
* <ExperimentalAxoBadge.Root size="md" value="unread" max={0} maxDisplay="" label="Marked unread" />
|
||||
* ```
|
||||
*/
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
const { value, max, maxDisplay } = props;
|
||||
const config = BadgeSizes[props.size];
|
||||
const { size, value, max, maxDisplay } = props;
|
||||
|
||||
const children = useMemo(() => {
|
||||
if (value === 'unread') {
|
||||
@@ -99,23 +125,20 @@ export namespace ExperimentalAxoBadge {
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return (
|
||||
<span aria-hidden className={config.countStyles}>
|
||||
<span aria-hidden className={CountSizes.get(size)}>
|
||||
{formatBadgeCount(value, max, maxDisplay)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
unreachable(value);
|
||||
}, [value, max, maxDisplay, config]);
|
||||
}, [size, value, max, maxDisplay]);
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-label={props['aria-label'] ?? undefined}
|
||||
className={config.rootStyles}
|
||||
>
|
||||
<span aria-label={props.label ?? undefined} className={Sizes.get(size)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = `${Namespace}.Root`;
|
||||
Root.displayName = 'AxoBadge.Root';
|
||||
}
|
||||
|
||||
@@ -4,11 +4,7 @@ import type { ReactNode, JSX } from 'react';
|
||||
import { useState } from 'react';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import {
|
||||
_getAllAxoButtonVariants,
|
||||
_getAllAxoButtonSizes,
|
||||
AxoButton,
|
||||
} from './AxoButton.dom.tsx';
|
||||
import { AxoButton } from './AxoButton.dom.tsx';
|
||||
import { tw } from './tw.dom.tsx';
|
||||
import { AxoSwitch } from './AxoSwitch.dom.tsx';
|
||||
|
||||
@@ -17,8 +13,8 @@ export default {
|
||||
} satisfies Meta;
|
||||
|
||||
export function Basic(): JSX.Element {
|
||||
const variants = _getAllAxoButtonVariants();
|
||||
const sizes = _getAllAxoButtonSizes();
|
||||
const variants = AxoButton._getAllVariants();
|
||||
const sizes = AxoButton._getAllSizes();
|
||||
return (
|
||||
<div className={tw('grid gap-1')}>
|
||||
{sizes.map(size => {
|
||||
@@ -93,19 +89,19 @@ export function Basic(): JSX.Element {
|
||||
}
|
||||
|
||||
export function Spinner(): JSX.Element {
|
||||
const sizes = _getAllAxoButtonSizes();
|
||||
const variants = _getAllAxoButtonVariants();
|
||||
const variants = AxoButton._getAllVariants();
|
||||
const sizes = AxoButton._getAllSizes();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pending, setPending] = useState(true);
|
||||
|
||||
function handleClick() {
|
||||
setLoading(true);
|
||||
setPending(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={tw('mb-4 flex gap-2')}>
|
||||
<AxoSwitch.Root checked={loading} onCheckedChange={setLoading} />
|
||||
<AxoSwitch.Root checked={pending} onCheckedChange={setPending} />
|
||||
<span>Loading</span>
|
||||
</div>
|
||||
<div className={tw('flex flex-col gap-2')}>
|
||||
@@ -117,10 +113,8 @@ export function Spinner(): JSX.Element {
|
||||
<AxoButton.Root
|
||||
variant={variant}
|
||||
size={size}
|
||||
disabled={loading}
|
||||
experimentalSpinner={
|
||||
loading ? { 'aria-label': 'Loading' } : null
|
||||
}
|
||||
disabled={pending}
|
||||
pending={pending}
|
||||
onClick={handleClick}
|
||||
>
|
||||
Save
|
||||
|
||||
+393
-280
@@ -1,316 +1,429 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { memo, forwardRef } from 'react';
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
FC,
|
||||
ForwardedRef,
|
||||
ReactNode,
|
||||
JSX,
|
||||
} from 'react';
|
||||
import type { TailwindStyles } from './tw.dom.tsx';
|
||||
import { memo, useCallback } from 'react';
|
||||
import type { FC, ReactNode, JSX, MouseEvent, Ref } from 'react';
|
||||
import { tw } from './tw.dom.tsx';
|
||||
import { AxoSymbol } from './AxoSymbol.dom.tsx';
|
||||
import { assert } from './_internal/assert.std.tsx';
|
||||
import type { SpinnerVariant } from '../components/SpinnerV2.dom.tsx';
|
||||
import { SpinnerV2 } from '../components/SpinnerV2.dom.tsx';
|
||||
import { useAxoIntl } from './_internal/AxoIntl.dom.tsx';
|
||||
import { variants } from './_internal/variants.dom.tsx';
|
||||
|
||||
const Namespace = 'AxoButton';
|
||||
|
||||
type GenericButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
const baseAxoButtonStyles = tw(
|
||||
'relative inline-flex max-w-full items-center-safe justify-center-safe rounded-full select-none',
|
||||
'outline-none keyboard-mode:focus:outline-focus-ring',
|
||||
'forced-colors:border'
|
||||
);
|
||||
|
||||
const AxoButtonTypes = {
|
||||
default: tw(baseAxoButtonStyles),
|
||||
subtle: tw(
|
||||
baseAxoButtonStyles,
|
||||
'bg-fill-secondary',
|
||||
'enabled:active:bg-fill-secondary-pressed'
|
||||
),
|
||||
floating: tw(
|
||||
baseAxoButtonStyles,
|
||||
'bg-fill-floating',
|
||||
'shadow-elevation-1',
|
||||
'enabled:active:bg-fill-floating-pressed'
|
||||
),
|
||||
borderless: tw(
|
||||
baseAxoButtonStyles,
|
||||
'bg-transparent',
|
||||
'enabled:hover:bg-fill-secondary',
|
||||
'enabled:active:bg-fill-secondary-pressed'
|
||||
),
|
||||
} as const satisfies Record<string, TailwindStyles>;
|
||||
|
||||
const AxoButtonVariants = {
|
||||
// default
|
||||
secondary: tw(
|
||||
AxoButtonTypes.default,
|
||||
'bg-fill-secondary text-label-primary',
|
||||
'enabled:active:bg-fill-secondary-pressed',
|
||||
'disabled:text-label-disabled'
|
||||
),
|
||||
primary: tw(
|
||||
AxoButtonTypes.default,
|
||||
'bg-color-fill-primary text-label-primary-on-color',
|
||||
'enabled:active:bg-color-fill-primary-pressed',
|
||||
'disabled:text-label-disabled-on-color'
|
||||
),
|
||||
affirmative: tw(
|
||||
AxoButtonTypes.default,
|
||||
'bg-color-fill-affirmative text-label-primary-on-color',
|
||||
'enabled:active:bg-color-fill-affirmative-pressed',
|
||||
'disabled:text-label-disabled-on-color'
|
||||
),
|
||||
destructive: tw(
|
||||
AxoButtonTypes.default,
|
||||
'bg-color-fill-destructive text-label-primary-on-color',
|
||||
'enabled:active:bg-color-fill-destructive-pressed',
|
||||
'disabled:text-label-disabled-on-color'
|
||||
),
|
||||
|
||||
// subtle
|
||||
'subtle-primary': tw(
|
||||
AxoButtonTypes.subtle,
|
||||
'text-color-label-primary',
|
||||
'disabled:text-color-label-primary-disabled'
|
||||
),
|
||||
'subtle-affirmative': tw(
|
||||
AxoButtonTypes.subtle,
|
||||
'text-color-label-affirmative',
|
||||
'disabled:text-color-label-affirmative-disabled'
|
||||
),
|
||||
'subtle-destructive': tw(
|
||||
AxoButtonTypes.subtle,
|
||||
'text-color-label-destructive',
|
||||
'disabled:text-color-label-destructive-disabled'
|
||||
),
|
||||
|
||||
// floating
|
||||
'floating-secondary': tw(
|
||||
AxoButtonTypes.floating,
|
||||
'text-label-primary',
|
||||
'disabled:text-label-disabled'
|
||||
),
|
||||
'floating-primary': tw(
|
||||
AxoButtonTypes.floating,
|
||||
'text-color-label-primary',
|
||||
'disabled:text-color-label-primary-disabled'
|
||||
),
|
||||
'floating-affirmative': tw(
|
||||
AxoButtonTypes.floating,
|
||||
'text-color-label-affirmative',
|
||||
'disabled:text-color-label-affirmative-disabled'
|
||||
),
|
||||
'floating-destructive': tw(
|
||||
AxoButtonTypes.floating,
|
||||
'text-color-label-destructive',
|
||||
'disabled:text-color-label-destructive-disabled'
|
||||
),
|
||||
|
||||
// borderless
|
||||
'borderless-secondary': tw(
|
||||
AxoButtonTypes.borderless,
|
||||
'text-label-primary',
|
||||
'disabled:text-label-disabled'
|
||||
),
|
||||
'borderless-primary': tw(
|
||||
AxoButtonTypes.borderless,
|
||||
'text-color-label-primary',
|
||||
'disabled:text-color-label-primary-disabled'
|
||||
),
|
||||
'borderless-affirmative': tw(
|
||||
AxoButtonTypes.borderless,
|
||||
'text-color-label-affirmative',
|
||||
'disabled:text-color-label-affirmative-disabled'
|
||||
),
|
||||
'borderless-destructive': tw(
|
||||
AxoButtonTypes.borderless,
|
||||
'text-color-label-destructive',
|
||||
'disabled:text-color-label-destructive-disabled'
|
||||
),
|
||||
};
|
||||
|
||||
const AxoButtonSizes = {
|
||||
lg: tw('min-w-16 px-4 py-2 type-body-medium font-medium'),
|
||||
md: tw('min-w-14 px-3 py-1.5 type-body-medium font-medium'),
|
||||
sm: tw('min-w-12 px-2 py-1 type-body-small font-medium'),
|
||||
} as const satisfies Record<string, TailwindStyles>;
|
||||
|
||||
type AxoButtonVariant = keyof typeof AxoButtonVariants;
|
||||
type AxoButtonSize = keyof typeof AxoButtonSizes;
|
||||
|
||||
/** @testexport */
|
||||
export function _getAllAxoButtonVariants(): ReadonlyArray<AxoButtonVariant> {
|
||||
return Object.keys(AxoButtonVariants) as Array<AxoButtonVariant>;
|
||||
}
|
||||
|
||||
/** @testexport */
|
||||
export function _getAllAxoButtonSizes(): ReadonlyArray<AxoButtonSize> {
|
||||
return Object.keys(AxoButtonSizes) as Array<AxoButtonSize>;
|
||||
}
|
||||
|
||||
const AxoButtonSpinnerVariants: Record<AxoButtonVariant, SpinnerVariant> = {
|
||||
primary: 'axo-button-spinner-on-color',
|
||||
secondary: 'axo-button-spinner-secondary',
|
||||
affirmative: 'axo-button-spinner-on-color',
|
||||
destructive: 'axo-button-spinner-on-color',
|
||||
'subtle-primary': 'axo-button-spinner-primary',
|
||||
'subtle-affirmative': 'axo-button-spinner-affirmative',
|
||||
'subtle-destructive': 'axo-button-spinner-destructive',
|
||||
'floating-primary': 'axo-button-spinner-primary',
|
||||
'floating-secondary': 'axo-button-spinner-secondary',
|
||||
'floating-affirmative': 'axo-button-spinner-affirmative',
|
||||
'floating-destructive': 'axo-button-spinner-destructive',
|
||||
'borderless-primary': 'axo-button-spinner-primary',
|
||||
'borderless-secondary': 'axo-button-spinner-secondary',
|
||||
'borderless-affirmative': 'axo-button-spinner-affirmative',
|
||||
'borderless-destructive': 'axo-button-spinner-destructive',
|
||||
};
|
||||
|
||||
const AxoButtonSpinnerSizes: Record<
|
||||
AxoButtonSize,
|
||||
{ size: number; strokeWidth: number }
|
||||
> = {
|
||||
lg: { size: 20, strokeWidth: 2 },
|
||||
md: { size: 20, strokeWidth: 2 },
|
||||
sm: { size: 16, strokeWidth: 1.5 },
|
||||
};
|
||||
|
||||
type ExperimentalButtonSpinnerProps = Readonly<{
|
||||
buttonVariant: AxoButtonVariant;
|
||||
buttonSize: AxoButtonSize;
|
||||
'aria-label': string;
|
||||
}>;
|
||||
|
||||
function ExperimentalButtonSpinner(
|
||||
props: ExperimentalButtonSpinnerProps
|
||||
): JSX.Element {
|
||||
const variant = AxoButtonSpinnerVariants[props.buttonVariant];
|
||||
const sizeConfig = AxoButtonSpinnerSizes[props.buttonSize];
|
||||
return (
|
||||
<span className={tw('absolute inset-0 flex items-center justify-center')}>
|
||||
<SpinnerV2
|
||||
size={sizeConfig.size}
|
||||
strokeWidth={sizeConfig.strokeWidth}
|
||||
variant={variant}
|
||||
value="indeterminate"
|
||||
ariaLabel={props['aria-label']}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A text button with optional leading icon and trailing arrow.
|
||||
*
|
||||
* @example Anatomy
|
||||
* ```tsx
|
||||
* <AxoButton.Root />
|
||||
* ```
|
||||
*
|
||||
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/button/ | Button Pattern - ARIA Authoring Practices Guide}
|
||||
* @see {@link https://w3c.github.io/aria/#button | `button` role - WAI-ARIA 1.3}
|
||||
*/
|
||||
export namespace AxoButton {
|
||||
export type Variant = AxoButtonVariant;
|
||||
export type Size = AxoButtonSize;
|
||||
/**
|
||||
* Visual style of the button.
|
||||
*/
|
||||
export type Variant =
|
||||
| 'secondary'
|
||||
| 'primary'
|
||||
| 'affirmative'
|
||||
| 'destructive'
|
||||
| 'subtle-primary'
|
||||
| 'subtle-affirmative'
|
||||
| 'subtle-destructive'
|
||||
| 'floating-secondary'
|
||||
| 'floating-primary'
|
||||
| 'floating-affirmative'
|
||||
| 'floating-destructive'
|
||||
| 'borderless-secondary'
|
||||
| 'borderless-primary'
|
||||
| 'borderless-affirmative'
|
||||
| 'borderless-destructive';
|
||||
|
||||
/**
|
||||
* Size of the button.
|
||||
*/
|
||||
export type Size = 'sm' | 'md' | 'lg';
|
||||
|
||||
/**
|
||||
* How the button sizes itself horizontally.
|
||||
* - `fit`: Shrinks to fit its content (default).
|
||||
* - `grow`: Expands to fill available space in a flex container.
|
||||
* - `full`: Always fills the full width of its container.
|
||||
*/
|
||||
export type Width = 'fit' | 'grow' | 'full';
|
||||
|
||||
// Note: Omitted 'prev' because arrow appears on trailing side,
|
||||
// back buttons should probably all use AxoIconButton.
|
||||
/**
|
||||
* Trailing arrow shown on the button.
|
||||
* - `next`: Chevron pointing forward, for navigation.
|
||||
* - `expand`: Chevron pointing down, for revealing content.
|
||||
* - `collapse`: Chevron pointing up, for hiding content.
|
||||
*
|
||||
* Note: Omitted 'prev' because arrow appears on trailing side,
|
||||
* back buttons should probably all use AxoIconButton.
|
||||
*/
|
||||
export type Arrow = 'collapse' | 'expand' | 'next';
|
||||
|
||||
const Widths: Record<Width, TailwindStyles> = {
|
||||
const baseStyles = tw(
|
||||
'relative inline-flex max-w-full items-center-safe justify-center-safe rounded-full select-none',
|
||||
'outline-none keyboard-mode:focus:outline-focus-ring',
|
||||
'forced-colors:border'
|
||||
);
|
||||
|
||||
const baseSubtleVariant = tw(
|
||||
baseStyles,
|
||||
'bg-fill-secondary',
|
||||
'not-aria-disabled:active:bg-fill-secondary-pressed'
|
||||
);
|
||||
|
||||
const baseFloatingVariant = tw(
|
||||
baseStyles,
|
||||
'bg-fill-floating',
|
||||
'shadow-elevation-1',
|
||||
'not-aria-disabled:active:bg-fill-floating-pressed'
|
||||
);
|
||||
|
||||
const baseBorderlessVariant = tw(
|
||||
baseStyles,
|
||||
'bg-transparent',
|
||||
'not-aria-disabled:hover:bg-fill-secondary',
|
||||
'not-aria-disabled:active:bg-fill-secondary-pressed'
|
||||
);
|
||||
|
||||
const VariantStyles = variants<Variant>('AxoButton.Variant', {
|
||||
// default
|
||||
secondary: tw(
|
||||
baseStyles,
|
||||
'bg-fill-secondary text-label-primary',
|
||||
'not-aria-disabled:active:bg-fill-secondary-pressed',
|
||||
'aria-disabled:text-label-disabled'
|
||||
),
|
||||
primary: tw(
|
||||
baseStyles,
|
||||
'bg-color-fill-primary text-label-primary-on-color',
|
||||
'not-aria-disabled:active:bg-color-fill-primary-pressed',
|
||||
'aria-disabled:text-label-disabled-on-color'
|
||||
),
|
||||
affirmative: tw(
|
||||
baseStyles,
|
||||
'bg-color-fill-affirmative text-label-primary-on-color',
|
||||
'not-aria-disabled:active:bg-color-fill-affirmative-pressed',
|
||||
'aria-disabled:text-label-disabled-on-color'
|
||||
),
|
||||
destructive: tw(
|
||||
baseStyles,
|
||||
'bg-color-fill-destructive text-label-primary-on-color',
|
||||
'not-aria-disabled:active:bg-color-fill-destructive-pressed',
|
||||
'aria-disabled:text-label-disabled-on-color'
|
||||
),
|
||||
|
||||
// subtle
|
||||
'subtle-primary': tw(
|
||||
baseSubtleVariant,
|
||||
'text-color-label-primary',
|
||||
'aria-disabled:text-color-label-primary-disabled'
|
||||
),
|
||||
'subtle-affirmative': tw(
|
||||
baseSubtleVariant,
|
||||
'text-color-label-affirmative',
|
||||
'aria-disabled:text-color-label-affirmative-disabled'
|
||||
),
|
||||
'subtle-destructive': tw(
|
||||
baseSubtleVariant,
|
||||
'text-color-label-destructive',
|
||||
'aria-disabled:text-color-label-destructive-disabled'
|
||||
),
|
||||
|
||||
// floating
|
||||
'floating-secondary': tw(
|
||||
baseFloatingVariant,
|
||||
'text-label-primary',
|
||||
'aria-disabled:text-label-disabled'
|
||||
),
|
||||
'floating-primary': tw(
|
||||
baseFloatingVariant,
|
||||
'text-color-label-primary',
|
||||
'aria-disabled:text-color-label-primary-disabled'
|
||||
),
|
||||
'floating-affirmative': tw(
|
||||
baseFloatingVariant,
|
||||
'text-color-label-affirmative',
|
||||
'aria-disabled:text-color-label-affirmative-disabled'
|
||||
),
|
||||
'floating-destructive': tw(
|
||||
baseFloatingVariant,
|
||||
'text-color-label-destructive',
|
||||
'aria-disabled:text-color-label-destructive-disabled'
|
||||
),
|
||||
|
||||
// borderless
|
||||
'borderless-secondary': tw(
|
||||
baseBorderlessVariant,
|
||||
'text-label-primary',
|
||||
'aria-disabled:text-label-disabled'
|
||||
),
|
||||
'borderless-primary': tw(
|
||||
baseBorderlessVariant,
|
||||
'text-color-label-primary',
|
||||
'aria-disabled:text-color-label-primary-disabled'
|
||||
),
|
||||
'borderless-affirmative': tw(
|
||||
baseBorderlessVariant,
|
||||
'text-color-label-affirmative',
|
||||
'aria-disabled:text-color-label-affirmative-disabled'
|
||||
),
|
||||
'borderless-destructive': tw(
|
||||
baseBorderlessVariant,
|
||||
'text-color-label-destructive',
|
||||
'aria-disabled:text-color-label-destructive-disabled'
|
||||
),
|
||||
});
|
||||
|
||||
const SizeStyles = variants<Size>('AxoButton.Size', {
|
||||
sm: tw('min-w-12 px-2 py-1 type-body-small font-medium'),
|
||||
md: tw('min-w-14 px-3 py-1.5 type-body-medium font-medium'),
|
||||
lg: tw('min-w-16 px-4 py-2 type-body-medium font-medium'),
|
||||
});
|
||||
|
||||
const WidthStyles = variants<Width>('AxoButton.Width', {
|
||||
/* Always try to fit to the content of the button */
|
||||
fit: tw(''),
|
||||
/* Allow the button to grow within a flex container */
|
||||
grow: tw('grow'),
|
||||
/* Always try to fill the available space */
|
||||
full: tw('w-full'),
|
||||
};
|
||||
});
|
||||
|
||||
const Arrows: Record<Arrow, AxoSymbol.InlineGlyphName> = {
|
||||
type ArrowSymbol = AxoSymbol.InlineGlyphName;
|
||||
const Arrows = variants<Arrow, ArrowSymbol>('AxoButton.Arrow', {
|
||||
collapse: 'chevron-up',
|
||||
expand: 'chevron-down',
|
||||
next: 'chevron-[end]',
|
||||
};
|
||||
});
|
||||
|
||||
/** @testexport */
|
||||
export function _getAllVariants(): ReadonlyArray<Variant> {
|
||||
return VariantStyles.keys();
|
||||
}
|
||||
|
||||
/** @testexport */
|
||||
export function _getAllSizes(): ReadonlyArray<Size> {
|
||||
return SizeStyles.keys();
|
||||
}
|
||||
|
||||
/**
|
||||
* <AxoButton.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type RootProps = Readonly<{
|
||||
variant: AxoButtonVariant;
|
||||
size: AxoButtonSize;
|
||||
/**
|
||||
* Ref to the underlying `<button>` element.
|
||||
*/
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
/**
|
||||
* Visual style of the button.
|
||||
*/
|
||||
variant: Variant;
|
||||
/**
|
||||
* Size of the button.
|
||||
*/
|
||||
size: Size;
|
||||
/**
|
||||
* How the button sizes itself horizontally. Defaults to `fit`.
|
||||
*/
|
||||
width?: Width;
|
||||
/**
|
||||
* Optional leading icon.
|
||||
*/
|
||||
symbol?: AxoSymbol.InlineGlyphName;
|
||||
/**
|
||||
* Optional trailing arrow icon.
|
||||
*/
|
||||
arrow?: Arrow | null;
|
||||
experimentalSpinner?: { 'aria-label': string } | null;
|
||||
disabled?: GenericButtonProps['disabled'];
|
||||
focusableWhenDisabled?: boolean;
|
||||
onClick?: GenericButtonProps['onClick'];
|
||||
'aria-expanded'?: boolean | null;
|
||||
'aria-controls'?: string | null;
|
||||
/**
|
||||
* When `true`, shows a loading spinner and prevents interaction.
|
||||
*/
|
||||
pending?: boolean | null;
|
||||
/**
|
||||
* When set, the button behaves as a toggle with `aria-pressed` semantics.
|
||||
*/
|
||||
pressed?: boolean | null;
|
||||
/**
|
||||
* When set, adds `aria-expanded` for disclosure buttons that show/hide content.
|
||||
*/
|
||||
expanded?: boolean | null;
|
||||
/**
|
||||
* `aria-controls` — the `id` of the element this button controls.
|
||||
*/
|
||||
controls?: string | null;
|
||||
/**
|
||||
* When `true`, prevents interaction.
|
||||
*/
|
||||
disabled?: boolean | null;
|
||||
/**
|
||||
* Called when the button is clicked.
|
||||
* Not called when `pending` or `disabled`.
|
||||
*/
|
||||
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
/**
|
||||
* The button label.
|
||||
*/
|
||||
children: ReactNode;
|
||||
// Note: Technically we forward all props for Radix, but we restrict the
|
||||
// props that the type accepts
|
||||
}>;
|
||||
|
||||
export const Root: FC<RootProps> = memo(
|
||||
forwardRef((props, ref: ForwardedRef<HTMLButtonElement>) => {
|
||||
const {
|
||||
variant,
|
||||
size,
|
||||
width,
|
||||
symbol,
|
||||
arrow,
|
||||
experimentalSpinner,
|
||||
disabled,
|
||||
focusableWhenDisabled,
|
||||
'aria-expanded': ariaExpanded,
|
||||
'aria-controls': ariaControls,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
const variantStyles = assert(
|
||||
AxoButtonVariants[variant],
|
||||
`${Namespace}: Invalid variant ${variant}`
|
||||
);
|
||||
const sizeStyles = assert(
|
||||
AxoButtonSizes[size],
|
||||
`${Namespace}: Invalid size ${size}`
|
||||
);
|
||||
const widthStyles = Widths[width ?? 'fit'];
|
||||
/**
|
||||
* A text button with an optional leading icon and trailing arrow.
|
||||
*
|
||||
* @example Dialog actions
|
||||
* ```tsx
|
||||
* <AxoButton.Root variant="secondary" size="md" width="grow" onClick={onCancel}>
|
||||
* Cancel
|
||||
* </AxoButton.Root>
|
||||
* <AxoButton.Root variant="primary" size="md" width="grow" pending={isSaving} onClick={onSave}>
|
||||
* Save
|
||||
* </AxoButton.Root>
|
||||
* ```
|
||||
*
|
||||
* @example Inline destructive action with icon
|
||||
* ```tsx
|
||||
* <AxoButton.Root
|
||||
* variant="subtle-destructive"
|
||||
* size="md"
|
||||
* symbol="trash"
|
||||
* onClick={onDelete}
|
||||
* >
|
||||
* Delete
|
||||
* </AxoButton.Root>
|
||||
* ```
|
||||
*/
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
const {
|
||||
variant,
|
||||
size,
|
||||
width,
|
||||
symbol,
|
||||
arrow,
|
||||
pending,
|
||||
disabled,
|
||||
pressed,
|
||||
expanded,
|
||||
controls,
|
||||
onClick,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={disabled && !focusableWhenDisabled}
|
||||
aria-disabled={focusableWhenDisabled && disabled}
|
||||
aria-expanded={ariaExpanded ?? undefined}
|
||||
aria-controls={ariaControls ?? undefined}
|
||||
{...rest}
|
||||
type="button"
|
||||
className={tw(variantStyles, sizeStyles, widthStyles)}
|
||||
>
|
||||
<span
|
||||
className={tw(
|
||||
'flex shrink grow items-center-safe justify-center-safe gap-1 overflow-hidden',
|
||||
experimentalSpinner != null ? 'opacity-0' : null
|
||||
)}
|
||||
>
|
||||
{symbol != null && (
|
||||
<AxoSymbol.InlineGlyph symbol={symbol} label={null} />
|
||||
)}
|
||||
<span className={tw('min-w-0 shrink grow truncate')}>
|
||||
{children}
|
||||
</span>
|
||||
{arrow != null && (
|
||||
<AxoSymbol.InlineGlyph symbol={Arrows[arrow]} label={null} />
|
||||
)}
|
||||
</span>
|
||||
{experimentalSpinner != null && (
|
||||
<ExperimentalButtonSpinner
|
||||
buttonVariant={variant}
|
||||
buttonSize={size}
|
||||
aria-label={experimentalSpinner['aria-label']}
|
||||
/>
|
||||
const intl = useAxoIntl();
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
if (pending || disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onClick?.(event);
|
||||
},
|
||||
[pending, disabled, onClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={props.ref}
|
||||
type="button"
|
||||
aria-label={pending ? intl.get('AxoButton.Pending') : undefined}
|
||||
aria-disabled={(pending || disabled) ?? undefined}
|
||||
aria-expanded={expanded ?? undefined}
|
||||
aria-pressed={pressed ?? undefined}
|
||||
aria-controls={controls ?? undefined}
|
||||
onClick={handleClick}
|
||||
className={tw(
|
||||
VariantStyles.get(variant),
|
||||
SizeStyles.get(size),
|
||||
WidthStyles.get(width ?? 'fit')
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<span
|
||||
aria-hidden={pending ?? undefined}
|
||||
className={tw(
|
||||
'flex shrink grow items-center-safe justify-center-safe gap-1 overflow-hidden',
|
||||
pending ? 'opacity-0' : null
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
>
|
||||
{symbol != null && (
|
||||
<AxoSymbol.InlineGlyph symbol={symbol} label={null} />
|
||||
)}
|
||||
<span className={tw('min-w-0 shrink grow truncate')}>{children}</span>
|
||||
{arrow != null && (
|
||||
<AxoSymbol.InlineGlyph symbol={Arrows.get(arrow)} label={null} />
|
||||
)}
|
||||
</span>
|
||||
{pending && <Spinner buttonVariant={variant} buttonSize={size} />}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = 'AxoButton.Root';
|
||||
|
||||
/**
|
||||
* <AxoButton.Spinner>
|
||||
* -------------------
|
||||
*/
|
||||
|
||||
const SpinnerVariants = variants<Variant, SpinnerVariant>(
|
||||
'AxoButton.Variant',
|
||||
{
|
||||
primary: 'axo-button-spinner-on-color',
|
||||
secondary: 'axo-button-spinner-secondary',
|
||||
affirmative: 'axo-button-spinner-on-color',
|
||||
destructive: 'axo-button-spinner-on-color',
|
||||
'subtle-primary': 'axo-button-spinner-primary',
|
||||
'subtle-affirmative': 'axo-button-spinner-affirmative',
|
||||
'subtle-destructive': 'axo-button-spinner-destructive',
|
||||
'floating-primary': 'axo-button-spinner-primary',
|
||||
'floating-secondary': 'axo-button-spinner-secondary',
|
||||
'floating-affirmative': 'axo-button-spinner-affirmative',
|
||||
'floating-destructive': 'axo-button-spinner-destructive',
|
||||
'borderless-primary': 'axo-button-spinner-primary',
|
||||
'borderless-secondary': 'axo-button-spinner-secondary',
|
||||
'borderless-affirmative': 'axo-button-spinner-affirmative',
|
||||
'borderless-destructive': 'axo-button-spinner-destructive',
|
||||
}
|
||||
);
|
||||
|
||||
Root.displayName = `${Namespace}.Root`;
|
||||
type SpinnerSizeConfig = Readonly<{
|
||||
size: number;
|
||||
strokeWidth: number;
|
||||
}>;
|
||||
|
||||
const SpinnerSizes = variants<Size, SpinnerSizeConfig>('AxoButton.Size', {
|
||||
lg: { size: 20, strokeWidth: 2 },
|
||||
md: { size: 20, strokeWidth: 2 },
|
||||
sm: { size: 16, strokeWidth: 1.5 },
|
||||
});
|
||||
|
||||
/** @internal */
|
||||
type SpinnerProps = Readonly<{
|
||||
buttonVariant: Variant;
|
||||
buttonSize: Size;
|
||||
}>;
|
||||
|
||||
/** @internal */
|
||||
function Spinner(props: SpinnerProps): JSX.Element {
|
||||
const variant = SpinnerVariants.get(props.buttonVariant);
|
||||
const sizeConfig = SpinnerSizes.get(props.buttonSize);
|
||||
return (
|
||||
<span className={tw('absolute inset-0 flex items-center justify-center')}>
|
||||
<SpinnerV2
|
||||
size={sizeConfig.size}
|
||||
strokeWidth={sizeConfig.strokeWidth}
|
||||
variant={variant}
|
||||
value="indeterminate"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export function Basic(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<h1 className={tw('type-title-large')}>AxoCheckbox</h1>
|
||||
{AxoCheckbox._getAllCheckboxVariants().map(variant => {
|
||||
{AxoCheckbox._getAllVariants().map(variant => {
|
||||
return (
|
||||
<section>
|
||||
<Template
|
||||
|
||||
+79
-27
@@ -1,47 +1,99 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { FC } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { Checkbox } from 'radix-ui';
|
||||
import { AxoSymbol } from './AxoSymbol.dom.tsx';
|
||||
import type { TailwindStyles } from './tw.dom.tsx';
|
||||
import { tw } from './tw.dom.tsx';
|
||||
import { variants } from './_internal/variants.dom.tsx';
|
||||
|
||||
const Namespace = 'AxoCheckbox';
|
||||
|
||||
/**
|
||||
* A control that allows the user to toggle between checked and not checked.
|
||||
*
|
||||
* TODO(jamie): We need to figure out a better way to label these.
|
||||
*
|
||||
* @example Anatomy
|
||||
* ```tsx
|
||||
* <AxoCheckbox.Root />
|
||||
* ```
|
||||
* @see {@link https://www.radix-ui.com/primitives/docs/components/checkbox | Checkbox - Radix Docs}
|
||||
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/checkbox/ | Checkbox Pattern - ARIA Authoring Practices Guide}
|
||||
* @see {@link https://w3c.github.io/aria/#checkbox | `checkbox` role - WAI-ARIA 1.3}
|
||||
*/
|
||||
export namespace AxoCheckbox {
|
||||
type VariantConfig = {
|
||||
rootStyles: TailwindStyles;
|
||||
iconSize: AxoSymbol.IconSize;
|
||||
};
|
||||
|
||||
const Variants: Record<Variant, VariantConfig> = {
|
||||
round: {
|
||||
rootStyles: tw('size-5 rounded-full'),
|
||||
iconSize: 14,
|
||||
},
|
||||
square: {
|
||||
rootStyles: tw('size-4 rounded-sm'),
|
||||
iconSize: 12,
|
||||
},
|
||||
};
|
||||
|
||||
export function _getAllCheckboxVariants(): ReadonlyArray<Variant> {
|
||||
return Object.keys(Variants) as Array<Variant>;
|
||||
}
|
||||
/**
|
||||
* <AxoCheckbox.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shape of the checkbox.
|
||||
* - `round`: Circular (20px). Used for selection in lists.
|
||||
* - `square`: Rounded-square (16px). Default for form checkboxes.
|
||||
*/
|
||||
export type Variant = 'round' | 'square';
|
||||
|
||||
const RootStyles = variants<Variant>('AxoCheckbox.Variant', {
|
||||
round: tw('size-5 rounded-full'),
|
||||
square: tw('size-4 rounded-sm'),
|
||||
});
|
||||
|
||||
const IconSizes = variants<Variant, AxoSymbol.IconSize>(
|
||||
'AxoCheckbox.Variant',
|
||||
{
|
||||
round: 14,
|
||||
square: 12,
|
||||
}
|
||||
);
|
||||
|
||||
/** @testexport */
|
||||
export function _getAllVariants(): ReadonlyArray<Variant> {
|
||||
return RootStyles.keys();
|
||||
}
|
||||
|
||||
export type RootProps = Readonly<{
|
||||
/**
|
||||
* HTML `id` used to associate a `<label htmlFor={id}>` with this checkbox.
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* Shape of the checkbox.
|
||||
*/
|
||||
variant: Variant;
|
||||
/**
|
||||
* The controlled checked state of the checkbox.
|
||||
* Must be used in conjunction with `onCheckedChange`.
|
||||
*/
|
||||
checked: boolean;
|
||||
/**
|
||||
* Event handler called when the checked state of the checkbox changes.
|
||||
*/
|
||||
onCheckedChange: (nextChecked: boolean) => void;
|
||||
/**
|
||||
* When `true`, prevents the user from interacting with the checkbox.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* When `true`, indicates that the user must check the checkbox before the
|
||||
* owning form can be submitted.
|
||||
*/
|
||||
required?: boolean;
|
||||
}>;
|
||||
|
||||
export const Root = memo((props: RootProps) => {
|
||||
const variantConfig = Variants[props.variant];
|
||||
/**
|
||||
* The checkbox control.
|
||||
*
|
||||
* @example Labeled checkbox
|
||||
* ```tsx
|
||||
* <AxoCheckbox.Root
|
||||
* id={id}
|
||||
* variant="square"
|
||||
* checked={includeMedia}
|
||||
* onCheckedChange={setIncludeMedia}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
return (
|
||||
<Checkbox.Root
|
||||
id={props.id}
|
||||
@@ -50,7 +102,7 @@ export namespace AxoCheckbox {
|
||||
disabled={props.disabled}
|
||||
required={props.required}
|
||||
className={tw(
|
||||
variantConfig.rootStyles,
|
||||
RootStyles.get(props.variant),
|
||||
'flex items-center justify-center',
|
||||
'border border-border-primary inset-shadow-on-color',
|
||||
'data-[state=unchecked]:bg-fill-primary',
|
||||
@@ -67,7 +119,7 @@ export namespace AxoCheckbox {
|
||||
<Checkbox.Indicator asChild>
|
||||
<AxoSymbol.Icon
|
||||
symbol="check"
|
||||
size={variantConfig.iconSize}
|
||||
size={IconSizes.get(props.variant)}
|
||||
label={null}
|
||||
/>
|
||||
</Checkbox.Indicator>
|
||||
@@ -75,5 +127,5 @@ export namespace AxoCheckbox {
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = `${Namespace}.Root`;
|
||||
Root.displayName = 'AxoCheckbox.Root';
|
||||
}
|
||||
|
||||
+129
-82
@@ -2,12 +2,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ContextMenu } from 'radix-ui';
|
||||
import type {
|
||||
FC,
|
||||
KeyboardEvent,
|
||||
KeyboardEventHandler,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
} from 'react';
|
||||
import type { FC, KeyboardEvent, MouseEvent } from 'react';
|
||||
import { AxoSymbol } from './AxoSymbol.dom.tsx';
|
||||
import { AxoBaseMenu } from './_internal/AxoBaseMenu.dom.tsx';
|
||||
import { tw } from './tw.dom.tsx';
|
||||
@@ -21,63 +16,88 @@ import { AxoTheme } from './AxoTheme.dom.tsx';
|
||||
|
||||
const { useDisableDragRegions } = AxoDragRegion;
|
||||
|
||||
const Namespace = 'AxoContextMenu';
|
||||
|
||||
/**
|
||||
* Displays a menu located at the pointer, triggered by a right click or a long press.
|
||||
* Displays a menu at the pointer position, triggered by a right-click or the
|
||||
* platform context-menu keyboard shortcut.
|
||||
*
|
||||
* Note: For menus that are triggered by a normal button press, you should use
|
||||
* `AxoDropdownMenu`.
|
||||
* For menus triggered by a button press, use `AxoDropdownMenu` instead.
|
||||
*
|
||||
* @example Anatomy
|
||||
* ```tsx
|
||||
* import { AxoContextMenu } from "./axo/ContextMenu/AxoContentMenu.tsx";
|
||||
*
|
||||
* export default () => (
|
||||
* <AxoContextMenu.Root>
|
||||
* <AxoContextMenu.Trigger />
|
||||
*
|
||||
* <AxoContextMenu.Root>
|
||||
* <AxoContextMenu.Trigger>
|
||||
* <AxoContextMenu.Content>
|
||||
* <AxoContextMenu.Label />
|
||||
* <AxoContextMenu.Item />
|
||||
*
|
||||
* <AxoContextMenu.Group>
|
||||
* <AxoContextMenu.Item />
|
||||
* </AxoContextMenu.Group>
|
||||
*
|
||||
* <AxoContextMenu.CheckboxItem/>
|
||||
*
|
||||
* <AxoContextMenu.CheckboxItem />
|
||||
* <AxoContextMenu.RadioGroup>
|
||||
* <AxoContextMenu.RadioItem/>
|
||||
* <AxoContextMenu.RadioItem />
|
||||
* </AxoContextMenu.RadioGroup>
|
||||
*
|
||||
* <AxoContextMenu.Sub>
|
||||
* <AxoContextMenu.SubTrigger />
|
||||
* <AxoContextMenu.SubContent />
|
||||
* </AxoContextMenu.Sub>
|
||||
*
|
||||
* <AxoContextMenu.Separator />
|
||||
* </AxoContextMenu.Content>
|
||||
* </AxoContextMenu.Root>
|
||||
* )
|
||||
* </AxoContextMenu.Trigger>
|
||||
* </AxoContextMenu.Root>
|
||||
* ```
|
||||
*
|
||||
* @see {@link https://www.radix-ui.com/primitives/docs/components/context-menu | Context Menu - Radix Docs}
|
||||
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/ | Menu Button Pattern - ARIA Authoring Practices Guide}
|
||||
* @see {@link https://w3c.github.io/aria/#menu | `menu` role - WAI-ARIA 1.3}
|
||||
*/
|
||||
export namespace AxoContextMenu {
|
||||
/**
|
||||
* <AxoContextMenu.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/** @internal */
|
||||
type RootContextType = Readonly<{
|
||||
open: boolean;
|
||||
}>;
|
||||
|
||||
/** @internal */
|
||||
const RootContext = createStrictContext<RootContextType>(
|
||||
`${Namespace}.RootContext`
|
||||
'AxoContextMenu.RootContext'
|
||||
);
|
||||
|
||||
/**
|
||||
* Component: <AxoContextMenu.Root>
|
||||
* --------------------------------
|
||||
*/
|
||||
|
||||
export type RootProps = AxoBaseMenu.MenuRootProps;
|
||||
|
||||
/**
|
||||
* Contains all the parts of a context menu.
|
||||
*
|
||||
* @example Conversation list item with context menu
|
||||
* ```tsx
|
||||
* <AxoContextMenu.Root onOpenChange={onOpenChange}>
|
||||
* <AxoContextMenu.Trigger>
|
||||
* <ConversationListItem conversation={conversation} />
|
||||
* </AxoContextMenu.Trigger>
|
||||
* <AxoContextMenu.Content>
|
||||
* <AxoContextMenu.Item symbol="message-badge" onSelect={onMarkUnread}>
|
||||
* Mark unread
|
||||
* </AxoContextMenu.Item>
|
||||
* <AxoContextMenu.Sub>
|
||||
* <AxoContextMenu.SubTrigger symbol="bell-slash">
|
||||
* Mute notifications
|
||||
* </AxoContextMenu.SubTrigger>
|
||||
* <AxoContextMenu.SubContent>
|
||||
* <AxoContextMenu.Item onSelect={() => onMute('1-hour')}>For 1 hour</AxoContextMenu.Item>
|
||||
* <AxoContextMenu.Item onSelect={() => onMute('always')}>Always</AxoContextMenu.Item>
|
||||
* </AxoContextMenu.SubContent>
|
||||
* </AxoContextMenu.Sub>
|
||||
* <AxoContextMenu.Separator />
|
||||
* <AxoContextMenu.Item symbol="trash" onSelect={onDelete}>
|
||||
* Delete conversation
|
||||
* </AxoContextMenu.Item>
|
||||
* </AxoContextMenu.Content>
|
||||
* </AxoContextMenu.Root>
|
||||
* ```
|
||||
*/
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
const { onOpenChange } = props;
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -105,15 +125,16 @@ export namespace AxoContextMenu {
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = `${Namespace}.Root`;
|
||||
Root.displayName = 'AxoContextMenu.Root';
|
||||
|
||||
/**
|
||||
* Component: <AxoContextMenu.Trigger>
|
||||
* -----------------------------------
|
||||
* <AxoContextMenu.Trigger>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
type TriggerElementGetter = (event: KeyboardEvent) => Element;
|
||||
|
||||
/** @internal */
|
||||
function useContextMenuTriggerKeyboardEventHandler(
|
||||
getTriggerElement: TriggerElementGetter
|
||||
) {
|
||||
@@ -143,7 +164,7 @@ export namespace AxoContextMenu {
|
||||
const clientRect = trigger.getBoundingClientRect();
|
||||
|
||||
trigger.dispatchEvent(
|
||||
new MouseEvent('contextmenu', {
|
||||
new globalThis.MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: clientRect.left,
|
||||
@@ -158,12 +179,17 @@ export namespace AxoContextMenu {
|
||||
|
||||
export type TriggerProps = AxoBaseMenu.MenuTriggerProps;
|
||||
|
||||
/**
|
||||
* The area that opens the context menu.
|
||||
* Wrap it around the target you want the context menu to open from when
|
||||
* right-clicking (or using the relevant keyboard shortcuts).
|
||||
*/
|
||||
export const Trigger: FC<TriggerProps> = memo(props => {
|
||||
const context = useStrictContext(RootContext);
|
||||
const [disableCurrentEvent, setDisableCurrentEvent] = useState(false);
|
||||
|
||||
const handleContextMenuCapture = useCallback(
|
||||
(event: ReactMouseEvent<HTMLElement>) => {
|
||||
(event: MouseEvent<HTMLElement>) => {
|
||||
const { target, currentTarget } = event;
|
||||
if (
|
||||
target instanceof HTMLElement &&
|
||||
@@ -207,20 +233,48 @@ export namespace AxoContextMenu {
|
||||
);
|
||||
});
|
||||
|
||||
Trigger.displayName = `${Namespace}.Trigger`;
|
||||
Trigger.displayName = 'AxoContextMenu.Trigger';
|
||||
|
||||
export function useAxoContextMenuOutsideKeyboardTrigger(): KeyboardEventHandler {
|
||||
/**
|
||||
* useAxoContextMenuOutsideKeyboardTrigger()
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns a `onKeyDown` handler that fires the context-menu keyboard shortcut
|
||||
* on the `AxoContextMenu.Trigger` found inside `event.currentTarget`.
|
||||
*
|
||||
* Use this when the keyboard handler must live on a parent element that is
|
||||
* separate from the `Trigger` (e.g. a focusable row that contains a trigger
|
||||
* deeper in its tree). Attach the returned handler to the focusable element's
|
||||
* `onKeyDown`.
|
||||
*
|
||||
* @example Row-level keyboard handler
|
||||
* ```tsx
|
||||
* const handleKeyDown = AxoContextMenu.useAxoContextMenuOutsideKeyboardTrigger();
|
||||
*
|
||||
* <div role="row" tabIndex={0} onKeyDown={handleKeyDown}>
|
||||
* <AxoContextMenu.Root>
|
||||
* <AxoContextMenu.Trigger>{rowContent}</AxoContextMenu.Trigger>
|
||||
* <AxoContextMenu.Content>...</AxoContextMenu.Content>
|
||||
* </AxoContextMenu.Root>
|
||||
* </div>
|
||||
* ```
|
||||
*/
|
||||
export function useAxoContextMenuOutsideKeyboardTrigger(): (
|
||||
event: KeyboardEvent
|
||||
) => void {
|
||||
return useContextMenuTriggerKeyboardEventHandler(event => {
|
||||
return assert(
|
||||
event.currentTarget.querySelector('[data-axo-contextmenu-trigger]'),
|
||||
`Couldn't find <${Namespace}.Trigger> element, did you forget to pass all html props through?`
|
||||
`Couldn't find <AxoContextMenu.Trigger> element, did you forget to pass all html props through?`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: <AxoContextMenu.Content>
|
||||
* -----------------------------------
|
||||
* <AxoContextMenu.Content>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ContentProps = AxoBaseMenu.MenuContentProps;
|
||||
@@ -248,23 +302,17 @@ export namespace AxoContextMenu {
|
||||
);
|
||||
});
|
||||
|
||||
Content.displayName = `${Namespace}.Content`;
|
||||
Content.displayName = 'AxoContextMenu.Content';
|
||||
|
||||
/**
|
||||
* Component: <AxoContextMenu.Item>
|
||||
* --------------------------------
|
||||
* <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>
|
||||
* ````
|
||||
* A single selectable item in the context menu.
|
||||
*/
|
||||
export const Item: FC<ItemProps> = memo(props => {
|
||||
return (
|
||||
@@ -291,11 +339,11 @@ export namespace AxoContextMenu {
|
||||
);
|
||||
});
|
||||
|
||||
Item.displayName = `${Namespace}.Item`;
|
||||
Item.displayName = 'AxoContextMenu.Item';
|
||||
|
||||
/**
|
||||
* Component: <AxoContextMenu.Group>
|
||||
* ---------------------------------
|
||||
* <AxoContextMenu.Group>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type GroupProps = AxoBaseMenu.MenuGroupProps;
|
||||
@@ -311,11 +359,11 @@ export namespace AxoContextMenu {
|
||||
);
|
||||
});
|
||||
|
||||
Group.displayName = `${Namespace}.Group`;
|
||||
Group.displayName = 'AxoContextMenu.Group';
|
||||
|
||||
/**
|
||||
* Component: <AxoContextMenu.Label>
|
||||
* ---------------------------------
|
||||
* <AxoContextMenu.Label>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type LabelProps = AxoBaseMenu.MenuLabelProps;
|
||||
@@ -333,11 +381,11 @@ export namespace AxoContextMenu {
|
||||
);
|
||||
});
|
||||
|
||||
Label.displayName = `${Namespace}.Label`;
|
||||
Label.displayName = 'AxoContextMenu.Label';
|
||||
|
||||
/**
|
||||
* Component: <AxoContextMenu.CheckboxItem>
|
||||
* ----------------------------------------
|
||||
* <AxoContextMenu.CheckboxItem>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type CheckboxItemProps = AxoBaseMenu.MenuCheckboxItemProps;
|
||||
@@ -379,11 +427,11 @@ export namespace AxoContextMenu {
|
||||
);
|
||||
});
|
||||
|
||||
CheckboxItem.displayName = `${Namespace}.CheckboxItem`;
|
||||
CheckboxItem.displayName = 'AxoContextMenu.CheckboxItem';
|
||||
|
||||
/**
|
||||
* Component: <AxoContextMenu.RadioGroup>
|
||||
* --------------------------------------
|
||||
* <AxoContextMenu.RadioGroup>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type RadioGroupProps = AxoBaseMenu.MenuRadioGroupProps;
|
||||
@@ -403,11 +451,11 @@ export namespace AxoContextMenu {
|
||||
);
|
||||
});
|
||||
|
||||
RadioGroup.displayName = `${Namespace}.RadioGroup`;
|
||||
RadioGroup.displayName = 'AxoContextMenu.RadioGroup';
|
||||
|
||||
/**
|
||||
* Component: <AxoContextMenu.RadioItem>
|
||||
* -------------------------------------
|
||||
* <AxoContextMenu.RadioItem>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type RadioItemProps = AxoBaseMenu.MenuRadioItemProps;
|
||||
@@ -446,11 +494,11 @@ export namespace AxoContextMenu {
|
||||
);
|
||||
});
|
||||
|
||||
RadioItem.displayName = `${Namespace}.RadioItem`;
|
||||
RadioItem.displayName = 'AxoContextMenu.RadioItem';
|
||||
|
||||
/**
|
||||
* Component: <AxoContextMenu.Separator>
|
||||
* -------------------------------------
|
||||
* <AxoContextMenu.Separator>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -462,11 +510,11 @@ export namespace AxoContextMenu {
|
||||
);
|
||||
});
|
||||
|
||||
Separator.displayName = `${Namespace}.Separator`;
|
||||
Separator.displayName = 'AxoContextMenu.Separator';
|
||||
|
||||
/**
|
||||
* Component: <AxoContextMenu.Sub>
|
||||
* -------------------------------
|
||||
* <AxoContextMenu.Sub>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type SubProps = AxoBaseMenu.MenuSubProps;
|
||||
@@ -478,18 +526,17 @@ export namespace AxoContextMenu {
|
||||
return <ContextMenu.Sub>{props.children}</ContextMenu.Sub>;
|
||||
});
|
||||
|
||||
Sub.displayName = `${Namespace}.Sub`;
|
||||
Sub.displayName = 'AxoContextMenu.Sub';
|
||||
|
||||
/**
|
||||
* Component: <AxoContextMenu.SubTrigger>
|
||||
* --------------------------------------
|
||||
* <AxoContextMenu.SubTrigger>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type SubTriggerProps = AxoBaseMenu.MenuSubTriggerProps;
|
||||
|
||||
/**
|
||||
* An item that opens a submenu. Must be rendered inside
|
||||
* {@link ContextMenu.Sub}.
|
||||
* An item that opens a submenu. Must be rendered inside `AxoContextMenu.Sub`.
|
||||
*/
|
||||
export const SubTrigger: FC<SubTriggerProps> = memo(props => {
|
||||
return (
|
||||
@@ -509,11 +556,11 @@ export namespace AxoContextMenu {
|
||||
);
|
||||
});
|
||||
|
||||
SubTrigger.displayName = `${Namespace}.SubTrigger`;
|
||||
SubTrigger.displayName = 'AxoContextMenu.SubTrigger';
|
||||
|
||||
/**
|
||||
* Component: <AxoContextMenu.SubContent>
|
||||
* --------------------------------------
|
||||
* <AxoContextMenu.SubContent>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type SubContentProps = AxoBaseMenu.MenuSubContentProps;
|
||||
@@ -534,5 +581,5 @@ export namespace AxoContextMenu {
|
||||
);
|
||||
});
|
||||
|
||||
SubContent.displayName = `${Namespace}.SubContent`;
|
||||
SubContent.displayName = 'AxoContextMenu.SubContent';
|
||||
}
|
||||
|
||||
@@ -54,11 +54,9 @@ function Template(props: {
|
||||
</AxoDialog.Trigger>
|
||||
<AxoDialog.Content size={props.contentSize} escape="cancel-is-noop">
|
||||
<AxoDialog.Header>
|
||||
{props.back && (
|
||||
<AxoDialog.Back aria-label="Back" onClick={action('onBack')} />
|
||||
)}
|
||||
{props.back && <AxoDialog.Back onClick={action('onBack')} />}
|
||||
<AxoDialog.Title>Title</AxoDialog.Title>
|
||||
<AxoDialog.Close aria-label="Close" />
|
||||
<AxoDialog.Close />
|
||||
</AxoDialog.Header>
|
||||
<AxoDialog.Body padding={props.bodyPadding}>
|
||||
{props.children}
|
||||
@@ -210,7 +208,7 @@ export function ExampleNicknameAndNoteDialog(): JSX.Element {
|
||||
<AxoDialog.Content size="sm" escape="cancel-is-destructive">
|
||||
<AxoDialog.Header>
|
||||
<AxoDialog.Title>Nickname</AxoDialog.Title>
|
||||
<AxoDialog.Close aria-label="Close" />
|
||||
<AxoDialog.Close />
|
||||
</AxoDialog.Header>
|
||||
<AxoDialog.Body>
|
||||
<p className={tw('mb-4 type-body-small text-label-secondary')}>
|
||||
@@ -278,7 +276,7 @@ export function ExampleMuteNotificationsDialog(): JSX.Element {
|
||||
<AxoDialog.Content size="sm" escape="cancel-is-noop">
|
||||
<AxoDialog.Header>
|
||||
<AxoDialog.Title>Mute notifications</AxoDialog.Title>
|
||||
<AxoDialog.Close aria-label="Close" />
|
||||
<AxoDialog.Close />
|
||||
</AxoDialog.Header>
|
||||
<AxoDialog.Body>
|
||||
<Spacer height={8} />
|
||||
@@ -345,7 +343,7 @@ export function ExampleLanguageDialog(): JSX.Element {
|
||||
<AxoDialog.Content size="sm" escape="cancel-is-noop">
|
||||
<AxoDialog.Header>
|
||||
<AxoDialog.Title>Language</AxoDialog.Title>
|
||||
<AxoDialog.Close aria-label="Close" />
|
||||
<AxoDialog.Close />
|
||||
</AxoDialog.Header>
|
||||
<AxoDialog.ExperimentalSearch>
|
||||
<input
|
||||
|
||||
+337
-82
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { Dialog } from 'radix-ui';
|
||||
import type { CSSProperties, FC, ReactNode } from 'react';
|
||||
import type { CSSProperties, FC, MouseEvent, ReactNode } from 'react';
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { AxoBaseDialog } from './_internal/AxoBaseDialog.dom.tsx';
|
||||
import type { AxoSymbol } from './AxoSymbol.dom.tsx';
|
||||
@@ -12,23 +12,113 @@ import { AxoButton } from './AxoButton.dom.tsx';
|
||||
import { AxoIconButton } from './AxoIconButton.dom.tsx';
|
||||
import { AxoTooltip } from './AxoTooltip.dom.tsx';
|
||||
import { AxoTheme } from './AxoTheme.dom.tsx';
|
||||
|
||||
const Namespace = 'AxoDialog';
|
||||
import { useAxoIntl } from './_internal/AxoIntl.dom.tsx';
|
||||
import { variants } from './_internal/variants.dom.tsx';
|
||||
|
||||
const { useContentEscapeBehavior } = AxoBaseDialog;
|
||||
|
||||
/**
|
||||
* A window overlaid on either the primary window or another dialog window,
|
||||
* rendering the content underneath inert.
|
||||
*
|
||||
* @example Anatomy
|
||||
* ```tsx
|
||||
* <AxoDialog.Root>
|
||||
* <AxoDialog.Trigger />
|
||||
* <AxoDialog.Content>
|
||||
* <AxoDialog.Header>
|
||||
* <AxoDialog.Back />
|
||||
* <AxoDialog.Title />
|
||||
* <AxoDialog.Close />
|
||||
* </AxoDialog.Header>
|
||||
* <AxoDialog.Body>
|
||||
* <AxoDialog.Description />
|
||||
* </AxoDialog.Body>
|
||||
* <AxoDialog.Footer>
|
||||
* <AxoDialog.FooterContent />
|
||||
* <AxoDialog.Actions>
|
||||
* <AxoDialog.Action/>
|
||||
* <AxoDialog.Action/>
|
||||
* </AxoDialog.Actions>
|
||||
* </AxoDialog.Footer>
|
||||
* </AxoDialog.Content>
|
||||
* </AxoDialog.Root>
|
||||
* ```
|
||||
*
|
||||
* @see {@link https://www.radix-ui.com/primitives/docs/components/dialog | Dialog - Radix Docs}
|
||||
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ | Dialog (Modal) Pattern - ARIA Authoring Practices Guide}
|
||||
* @see {@link https://w3c.github.io/aria/#dialog | `dialog` role - WAI-ARIA 1.3}
|
||||
*/
|
||||
export namespace AxoDialog {
|
||||
/**
|
||||
* Component: <AxoDialog.Root>
|
||||
* ---------------------------
|
||||
* <AxoDialog.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type RootProps = Readonly<{
|
||||
/**
|
||||
* The controlled open state of the dialog.
|
||||
* Must be used in conjunction with `onOpenChange`.
|
||||
*/
|
||||
open?: boolean;
|
||||
/**
|
||||
* Event handler called when the open state of the dialog changes.
|
||||
*/
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/**
|
||||
* Should be a `Trigger` and `Content`.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Contains all the parts of a dialog.
|
||||
*
|
||||
* @example Controlled dialog (most common)
|
||||
* ```tsx
|
||||
* <AxoDialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
* <AxoDialog.Content size="sm" escape="cancel-is-noop">
|
||||
* <AxoDialog.Header>
|
||||
* <AxoDialog.Title>Delete attachment?</AxoDialog.Title>
|
||||
* <AxoDialog.Close />
|
||||
* </AxoDialog.Header>
|
||||
* <AxoDialog.Body>
|
||||
* <AxoDialog.Description>
|
||||
* This attachment will be permanently deleted.
|
||||
* </AxoDialog.Description>
|
||||
* </AxoDialog.Body>
|
||||
* <AxoDialog.Footer>
|
||||
* <AxoDialog.Actions>
|
||||
* <AxoDialog.Action variant="secondary" onClick={onCancel}>
|
||||
* Cancel
|
||||
* </AxoDialog.Action>
|
||||
* <AxoDialog.Action variant="destructive" onClick={onDelete}>
|
||||
* Delete
|
||||
* </AxoDialog.Action>
|
||||
* </AxoDialog.Actions>
|
||||
* </AxoDialog.Footer>
|
||||
* </AxoDialog.Content>
|
||||
* </AxoDialog.Root>
|
||||
* ```
|
||||
*
|
||||
* @example Trigger-based dialog
|
||||
* ```tsx
|
||||
* <AxoDialog.Root>
|
||||
* <AxoDialog.Trigger>
|
||||
* <AxoButton.Root variant="secondary" size="md" width="fit" onClick={noop}>
|
||||
* Open settings
|
||||
* </AxoButton.Root>
|
||||
* </AxoDialog.Trigger>
|
||||
* <AxoDialog.Content size="md" escape="cancel-is-destructive">
|
||||
* <AxoDialog.Header>
|
||||
* <AxoDialog.Title>Settings</AxoDialog.Title>
|
||||
* <AxoDialog.Close />
|
||||
* </AxoDialog.Header>
|
||||
* <AxoDialog.Body>...</AxoDialog.Body>
|
||||
* </AxoDialog.Content>
|
||||
* </AxoDialog.Root>
|
||||
* ```
|
||||
*/
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
return (
|
||||
<Dialog.Root open={props.open} onOpenChange={props.onOpenChange} modal>
|
||||
@@ -37,51 +127,81 @@ export namespace AxoDialog {
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = `${Namespace}.Root`;
|
||||
Root.displayName = 'AxoDialog.Root';
|
||||
|
||||
/**
|
||||
* Component: <AxoDialog.Trigger>
|
||||
* ------------------------------
|
||||
* <AxoDialog.Trigger>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type TriggerProps = Readonly<{
|
||||
/**
|
||||
* The element that opens the dialog when clicked.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The button that opens the dialog.
|
||||
*/
|
||||
export const Trigger: FC<TriggerProps> = memo(props => {
|
||||
return <Dialog.Trigger asChild>{props.children}</Dialog.Trigger>;
|
||||
});
|
||||
|
||||
Trigger.displayName = `${Namespace}.Trigger`;
|
||||
Trigger.displayName = 'AxoDialog.Trigger';
|
||||
|
||||
/**
|
||||
* Component: <AxoDialog.Content>
|
||||
* ------------------------------
|
||||
* <AxoDialog.Content>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
type ContentSizeConfig = Readonly<{
|
||||
width: number;
|
||||
minWidth: number;
|
||||
}>;
|
||||
|
||||
const ContentSizes: Record<ContentSize, ContentSizeConfig> = {
|
||||
xs: { width: 300, minWidth: 300 },
|
||||
sm: { width: 360, minWidth: 360 },
|
||||
md: { width: 420, minWidth: 360 },
|
||||
lg: { width: 720, minWidth: 360 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Width of the dialog.
|
||||
* - `xs` – 300px
|
||||
* - `sm` – 360px
|
||||
* - `md` – 420px
|
||||
* - `lg` – 720px
|
||||
*/
|
||||
export type ContentSize = 'xs' | 'sm' | 'md' | 'lg';
|
||||
|
||||
/**
|
||||
* How dangerous the cancel action is considered.
|
||||
* - `cancel-is-noop`: Canceling is safe — pressing Escape or clicking outside closes the dialog.
|
||||
* - `cancel-is-destructive`: Canceling would lose user state — pressing Escape or clicking outside is disabled.
|
||||
*/
|
||||
export type ContentEscape = AxoBaseDialog.ContentEscape;
|
||||
|
||||
const ContentSizeStyles = variants<ContentSize>('AxoDialog.ContentSize', {
|
||||
xs: tw('w-[300px] min-w-[300px]'),
|
||||
sm: tw('w-[360px] min-w-[360px]'),
|
||||
md: tw('w-[420px] min-w-[360px]'),
|
||||
lg: tw('w-[720px] min-w-[360px]'),
|
||||
});
|
||||
|
||||
export type ContentProps = Readonly<{
|
||||
/**
|
||||
* Width of the dialog.
|
||||
*/
|
||||
size: ContentSize;
|
||||
/**
|
||||
* What happens when the user presses `Escape` or clicks outside.
|
||||
*/
|
||||
escape: ContentEscape;
|
||||
/**
|
||||
* Suppresses the Radix UI warning about a missing `aria-describedby`.
|
||||
* Prefer adding a visually-hidden `Description` instead of using this.
|
||||
*/
|
||||
disableMissingAriaDescriptionWarning?: boolean;
|
||||
/**
|
||||
* Should be `Header`, `Body`, `Footer`, and/or `Description` elements.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Contains content to be rendered in the open dialog.
|
||||
*/
|
||||
export const Content: FC<ContentProps> = memo(props => {
|
||||
const sizeConfig = ContentSizes[props.size];
|
||||
const handleContentEscapeEvent = useContentEscapeBehavior(props.escape);
|
||||
const [boundary, setBoundary] = useState<Element | null>(null);
|
||||
|
||||
@@ -102,13 +222,12 @@ export namespace AxoDialog {
|
||||
<AxoTooltip.CollisionBoundary boundary={boundary} padding={4}>
|
||||
<Dialog.Content
|
||||
ref={setBoundary}
|
||||
className={AxoBaseDialog.contentStyles}
|
||||
className={tw(
|
||||
AxoBaseDialog.contentStyles,
|
||||
ContentSizeStyles.get(props.size)
|
||||
)}
|
||||
onEscapeKeyDown={handleContentEscapeEvent}
|
||||
onInteractOutside={handleContentEscapeEvent}
|
||||
style={{
|
||||
width: sizeConfig.width,
|
||||
minWidth: 320,
|
||||
}}
|
||||
{...descriptionProps}
|
||||
>
|
||||
{props.children}
|
||||
@@ -120,17 +239,25 @@ export namespace AxoDialog {
|
||||
);
|
||||
});
|
||||
|
||||
Content.displayName = `${Namespace}.Content`;
|
||||
Content.displayName = 'AxoDialog.Content';
|
||||
|
||||
/**
|
||||
* Component: <AxoDialog.Header>
|
||||
* -----------------------------
|
||||
* <AxoDialog.Header>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type HeaderProps = Readonly<{
|
||||
/**
|
||||
* Should be `Back`, `Title`, and/or `Close` elements.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A three-column grid header: back button on the left, title in the center,
|
||||
* close button on the right. Omitting `Back` or `Close` leaves their column
|
||||
* empty so the title stays centered.
|
||||
*/
|
||||
export const Header: FC<HeaderProps> = memo(props => {
|
||||
return (
|
||||
<div
|
||||
@@ -144,18 +271,28 @@ export namespace AxoDialog {
|
||||
);
|
||||
});
|
||||
|
||||
Header.displayName = `${Namespace}.Header`;
|
||||
Header.displayName = 'AxoDialog.Header';
|
||||
|
||||
/**
|
||||
* Component: <AxoDialog.Title>
|
||||
* ----------------------------
|
||||
* <AxoDialog.Title>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type TitleProps = Readonly<{
|
||||
/**
|
||||
* There must always be a title for the dialog, but if you don't want it to
|
||||
* be visually displayed you can pass `screenReaderOnly: true`
|
||||
*/
|
||||
screenReaderOnly?: boolean;
|
||||
/**
|
||||
* The title text.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* An accessible title to be announced when the dialog is opened.
|
||||
*/
|
||||
export const Title: FC<TitleProps> = memo(props => {
|
||||
return (
|
||||
<Dialog.Title
|
||||
@@ -171,26 +308,32 @@ export namespace AxoDialog {
|
||||
);
|
||||
});
|
||||
|
||||
Title.displayName = `${Namespace}.Title`;
|
||||
Title.displayName = 'AxoDialog.Title';
|
||||
|
||||
/**
|
||||
* Component: <AxoDialog.Back>
|
||||
* ---------------------------
|
||||
* <AxoDialog.Back>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type BackProps = Readonly<{
|
||||
'aria-label': string;
|
||||
onClick: () => void;
|
||||
/**
|
||||
* Called when the back button is clicked.
|
||||
*/
|
||||
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A back-navigation button rendered in the leading column of `Header`.
|
||||
*/
|
||||
export const Back: FC<BackProps> = memo(props => {
|
||||
const intl = useAxoIntl();
|
||||
return (
|
||||
<div className={tw('col-[back-slot] text-start')}>
|
||||
<AxoIconButton.Root
|
||||
size="sm"
|
||||
variant="borderless-secondary"
|
||||
symbol="chevron-[start]"
|
||||
label={props['aria-label']}
|
||||
label={intl.get('AxoDialog.Back')}
|
||||
tooltip={false}
|
||||
onClick={props.onClick}
|
||||
/>
|
||||
@@ -198,18 +341,18 @@ export namespace AxoDialog {
|
||||
);
|
||||
});
|
||||
|
||||
Back.displayName = `${Namespace}.Back`;
|
||||
Back.displayName = 'AxoDialog.Back';
|
||||
|
||||
/**
|
||||
* Component: <AxoDialog.Close>
|
||||
* ----------------------------
|
||||
* <AxoDialog.Close>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type CloseProps = Readonly<{
|
||||
'aria-label': string;
|
||||
}>;
|
||||
|
||||
export const Close: FC<CloseProps> = memo(props => {
|
||||
/**
|
||||
* The button that closes the dialog.
|
||||
*/
|
||||
export const Close: FC = memo(() => {
|
||||
const intl = useAxoIntl();
|
||||
return (
|
||||
<div className={tw('col-[close-slot] text-end leading-none')}>
|
||||
<Dialog.Close asChild>
|
||||
@@ -217,7 +360,7 @@ export namespace AxoDialog {
|
||||
size="sm"
|
||||
variant="borderless-secondary"
|
||||
symbol="x"
|
||||
label={props['aria-label']}
|
||||
label={intl.get('AxoDialog.Close')}
|
||||
tooltip={false}
|
||||
/>
|
||||
</Dialog.Close>
|
||||
@@ -225,31 +368,66 @@ export namespace AxoDialog {
|
||||
);
|
||||
});
|
||||
|
||||
Close.displayName = `${Namespace}.Close`;
|
||||
Close.displayName = 'AxoDialog.Close';
|
||||
|
||||
/**
|
||||
* <AxoDialog.Search>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ExperimentalSearchProps = Readonly<{
|
||||
/**
|
||||
* A search input element.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A padded slot for a search input, placed between `Header` and `Body`.
|
||||
* Pair with `Body` using `padding="only-scrollbar-gutter"` so the list
|
||||
* content aligns with the search field.
|
||||
*/
|
||||
export const ExperimentalSearch: FC<ExperimentalSearchProps> = memo(props => {
|
||||
return <div className={tw('px-4 pb-2')}>{props.children}</div>;
|
||||
});
|
||||
|
||||
ExperimentalSearch.displayName = `${Namespace}.ExperimentalSearch`;
|
||||
ExperimentalSearch.displayName = 'AxoDialog.ExperimentalSearch';
|
||||
|
||||
/**
|
||||
* Component: <AxoDialog.Body>
|
||||
* ---------------------------
|
||||
* <AxoDialog.Body>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Horizontal padding applied to the body content.
|
||||
* - `normal`: Standard 24px inline padding (default).
|
||||
* - `only-scrollbar-gutter`: No padding, only reserves space for the scrollbar.
|
||||
* Use when content (e.g. a list) provides its own padding, or when paired
|
||||
* with `ExperimentalSearch` so items align with the search field.
|
||||
*/
|
||||
export type BodyPadding = 'normal' | 'only-scrollbar-gutter';
|
||||
|
||||
export type BodyProps = Readonly<{
|
||||
/**
|
||||
* Horizontal padding applied to the body content.
|
||||
* Defaults to `normal`.
|
||||
*/
|
||||
padding?: BodyPadding;
|
||||
/**
|
||||
* Maximum height before the body becomes scrollable.
|
||||
* Defaults to `440`.
|
||||
*/
|
||||
maxHeight?: number;
|
||||
/**
|
||||
* The scrollable body content.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Scrollable content area between `Header` and `Footer`.
|
||||
* Automatically shows scroll hints and a thin scrollbar.
|
||||
*/
|
||||
export const Body: FC<BodyProps> = memo(props => {
|
||||
const { padding = 'normal', maxHeight = 440 } = props;
|
||||
|
||||
@@ -280,32 +458,44 @@ export namespace AxoDialog {
|
||||
);
|
||||
});
|
||||
|
||||
Body.displayName = `${Namespace}.Body`;
|
||||
Body.displayName = 'AxoDialog.Body';
|
||||
|
||||
/**
|
||||
* Component: <AxoDialog.Description>
|
||||
* ----------------------------------
|
||||
* <AxoDialog.Description>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type DescriptionProps = Readonly<{
|
||||
/**
|
||||
* The description text.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* An optional accessible description to be announced when the dialog is opened.
|
||||
*/
|
||||
export const Description: FC<DescriptionProps> = memo(props => {
|
||||
return <Dialog.Description>{props.children}</Dialog.Description>;
|
||||
});
|
||||
|
||||
Description.displayName = `${Namespace}.Description`;
|
||||
Description.displayName = 'AxoDialog.Description';
|
||||
|
||||
/**
|
||||
* Component: <AxoDialog.Body>
|
||||
* ---------------------------
|
||||
* <AxoDialog.Body>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type FooterProps = Readonly<{
|
||||
/**
|
||||
* Should be `FooterContent` and/or `Actions` elements.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A row of action buttons at the bottom of the dialog.
|
||||
*/
|
||||
export const Footer: FC<FooterProps> = memo(props => {
|
||||
return (
|
||||
<div className={tw('flex flex-wrap items-center gap-3 px-3 py-2.5')}>
|
||||
@@ -314,17 +504,26 @@ export namespace AxoDialog {
|
||||
);
|
||||
});
|
||||
|
||||
Footer.displayName = `${Namespace}.Footer`;
|
||||
Footer.displayName = 'AxoDialog.Footer';
|
||||
|
||||
/**
|
||||
* Component: <AxoDialog.FooterContent>
|
||||
* ------------------------------------
|
||||
* <AxoDialog.FooterContent>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type FooterContentProps = Readonly<{
|
||||
/**
|
||||
* Supplementary text shown alongside the action buttons.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Optional text content placed in `Footer` alongside `Actions`.
|
||||
*
|
||||
* Flows into its own row when the available width is too narrow to share a
|
||||
* line.
|
||||
*/
|
||||
export const FooterContent: FC<FooterContentProps> = memo(props => {
|
||||
return (
|
||||
<div
|
||||
@@ -346,17 +545,23 @@ export namespace AxoDialog {
|
||||
);
|
||||
});
|
||||
|
||||
FooterContent.displayName = `${Namespace}.FooterContent`;
|
||||
FooterContent.displayName = 'AxoDialog.FooterContent';
|
||||
|
||||
/**
|
||||
* Component: <AxoDialog.Actions>
|
||||
* ------------------------------
|
||||
* <AxoDialog.Actions>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ActionsProps = Readonly<{
|
||||
/**
|
||||
* Should be `Action` and/or `IconAction` elements.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A right-aligned group of action buttons inside `Footer`.
|
||||
*/
|
||||
export const Actions: FC<ActionsProps> = memo(props => {
|
||||
return (
|
||||
<div
|
||||
@@ -375,35 +580,63 @@ export namespace AxoDialog {
|
||||
);
|
||||
});
|
||||
|
||||
Actions.displayName = `${Namespace}.Actions`;
|
||||
Actions.displayName = 'AxoDialog.Actions';
|
||||
|
||||
/**
|
||||
* Component: <AxoDialog.Actions>
|
||||
* ------------------------------
|
||||
* <AxoDialog.Actions>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Visual style of an action button.
|
||||
* - `primary`: High-emphasis confirm action.
|
||||
* - `secondary`: Low-emphasis cancel or alternative action.
|
||||
* - `destructive`: Irreversible or dangerous action.
|
||||
*/
|
||||
export type ActionVariant = 'primary' | 'destructive' | 'secondary';
|
||||
|
||||
export type ActionProps = Readonly<{
|
||||
/**
|
||||
* Visual style of the button.
|
||||
*/
|
||||
variant: ActionVariant;
|
||||
/**
|
||||
* Optional leading icon.
|
||||
*/
|
||||
symbol?: AxoSymbol.InlineGlyphName;
|
||||
arrow?: boolean;
|
||||
experimentalSpinner?: { 'aria-label': string } | null;
|
||||
disabled?: boolean;
|
||||
focusableWhenDisabled?: boolean;
|
||||
onClick: () => void;
|
||||
/**
|
||||
* When `true`, shows a forward arrow on the trailing side.
|
||||
*/
|
||||
arrow?: boolean | null;
|
||||
/**
|
||||
* When `true`, shows a loading spinner and prevents interaction.
|
||||
*/
|
||||
pending?: boolean | null;
|
||||
/**
|
||||
* When `true`, prevents interaction.
|
||||
*/
|
||||
disabled?: boolean | null;
|
||||
/**
|
||||
* Event handler called when the button is clicked.
|
||||
*/
|
||||
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
/**
|
||||
* The button label.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A button for use inside `Actions`.
|
||||
*/
|
||||
export const Action: FC<ActionProps> = memo(props => {
|
||||
return (
|
||||
<AxoButton.Root
|
||||
variant={props.variant}
|
||||
symbol={props.symbol}
|
||||
arrow={props.arrow ? 'next' : null}
|
||||
experimentalSpinner={props.experimentalSpinner}
|
||||
pending={props.pending}
|
||||
disabled={props.disabled}
|
||||
focusableWhenDisabled={props.focusableWhenDisabled}
|
||||
size="md"
|
||||
width="grow"
|
||||
onClick={props.onClick}
|
||||
@@ -413,22 +646,44 @@ export namespace AxoDialog {
|
||||
);
|
||||
});
|
||||
|
||||
Action.displayName = `${Namespace}.Action`;
|
||||
Action.displayName = 'AxoDialog.Action';
|
||||
|
||||
/**
|
||||
* Component: <AxoDialog.IconAction>
|
||||
* ---------------------------------
|
||||
* <AxoDialog.IconAction>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Visual style of an icon action button.
|
||||
* - `primary`: High-emphasis confirm action.
|
||||
* - `secondary`: Low-emphasis cancel or alternative action.
|
||||
* - `destructive`: Irreversible or dangerous action.
|
||||
*/
|
||||
export type IconActionVariant = 'primary' | 'destructive' | 'secondary';
|
||||
|
||||
export type IconActionProps = Readonly<{
|
||||
/**
|
||||
* Accessible label for screen readers.
|
||||
* Should describe the action of the button, not the icon.
|
||||
*/
|
||||
label: string;
|
||||
variant: ActionVariant;
|
||||
/**
|
||||
* Visual style of the button.
|
||||
*/
|
||||
variant: IconActionVariant;
|
||||
/**
|
||||
* The icon to display.
|
||||
*/
|
||||
symbol: AxoSymbol.IconName;
|
||||
onClick: () => void;
|
||||
/**
|
||||
* Event handler called when the button is clicked.
|
||||
*/
|
||||
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* An icon-only button for use inside `Actions`.
|
||||
*/
|
||||
export const IconAction: FC<IconActionProps> = memo(props => {
|
||||
return (
|
||||
<AxoIconButton.Root
|
||||
@@ -441,5 +696,5 @@ export namespace AxoDialog {
|
||||
);
|
||||
});
|
||||
|
||||
IconAction.displayName = `${Namespace}.IconAction`;
|
||||
IconAction.displayName = 'AxoDialog.IconAction';
|
||||
}
|
||||
|
||||
@@ -5,19 +5,53 @@ import { memo, useEffect } from 'react';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { Slot } from 'radix-ui';
|
||||
|
||||
const Namespace = 'AxoDragRegion';
|
||||
|
||||
/**
|
||||
* Marks an element as a native window drag region, allowing the user to drag
|
||||
* the app window by clicking and dragging that area (Electron `-webkit-app-region: drag`).
|
||||
*
|
||||
* @example Anatomy
|
||||
* ```tsx
|
||||
* <AxoDragRegion.Root>
|
||||
* {children}
|
||||
* </AxoDragRegion.Root>
|
||||
* ```
|
||||
*/
|
||||
export namespace AxoDragRegion {
|
||||
/**
|
||||
* <AxoDragRegion.Root>
|
||||
* --------------------
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type RootProps = Readonly<{
|
||||
/**
|
||||
* When `true`, the region remains draggable even while `useDisableDragRegions`
|
||||
* is active. Used in title bar which should always be draggable.
|
||||
*/
|
||||
always?: boolean;
|
||||
/**
|
||||
* The element to mark as a drag region. The attribute is applied directly
|
||||
* to the child element via `Slot` — no wrapper DOM node is added.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Marks its child element as a native window drag region.
|
||||
*
|
||||
* @example Title bar (always draggable)
|
||||
* ```tsx
|
||||
* <AxoDragRegion.Root always>
|
||||
* <div className={tw('h-8')} />
|
||||
* </AxoDragRegion.Root>
|
||||
* ```
|
||||
*
|
||||
* @example Sidebar header (draggable when no overlay is open)
|
||||
* ```tsx
|
||||
* <AxoDragRegion.Root>
|
||||
* <header>{children}</header>
|
||||
* </AxoDragRegion.Root>
|
||||
* ```
|
||||
*/
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
return (
|
||||
<Slot.Root data-axo-drag-region={props.always ? 'always' : 'auto'}>
|
||||
@@ -26,19 +60,27 @@ export namespace AxoDragRegion {
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = `${Namespace}.Root`;
|
||||
Root.displayName = 'AxoDragRegion.Root';
|
||||
|
||||
/**
|
||||
* useDisableDragRegions()
|
||||
* -----------------------
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const DISABLE_ATTRIBUTE = 'data-axo-drag-region-disable';
|
||||
|
||||
/**
|
||||
* New elements added to the DOM may not trigger a recalculation of draggable regions,
|
||||
* which can cause pointer events on elements rendered on top of a draggable region to be
|
||||
* ignored incorrectly.
|
||||
* Suspends all non-`always` drag regions while `condition` is `true`.
|
||||
*
|
||||
* Call this in any overlay (menus, call bars) that renders on top of a drag
|
||||
* region. New DOM elements don't always trigger a recalculation of draggable
|
||||
* regions in Electron, which can cause pointer events on overlapping elements
|
||||
* to be silently swallowed.
|
||||
*
|
||||
* @example Disable drag regions while a menu is open
|
||||
* ```tsx
|
||||
* useDisableDragRegions(open);
|
||||
* ```
|
||||
*/
|
||||
export function useDisableDragRegions(condition: boolean): void {
|
||||
useEffect(() => {
|
||||
|
||||
+138
-105
@@ -35,8 +35,6 @@ import { AxoTheme } from './AxoTheme.dom.tsx';
|
||||
|
||||
const { useDisableDragRegions } = AxoDragRegion;
|
||||
|
||||
const Namespace = 'AxoDropdownMenu';
|
||||
|
||||
/**
|
||||
* Displays a menu to the user—such as a set of actions or functions—triggered
|
||||
* by a button.
|
||||
@@ -46,64 +44,82 @@ const Namespace = 'AxoDropdownMenu';
|
||||
*
|
||||
* @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>
|
||||
* )
|
||||
* <AxoDropdownMenu.Root>
|
||||
* <AxoDropdownMenu.Trigger>
|
||||
* <AxoIconButton.Root variant="secondary" size="sm" symbol="more" label="More options" />
|
||||
* </AxoDropdownMenu.Trigger>
|
||||
* <AxoDropdownMenu.Content>
|
||||
* <AxoDropdownMenu.Header label="Conversation" />
|
||||
* <AxoDropdownMenu.Item symbol="bell-slash" onSelect={onMute}>Mute notifications</AxoDropdownMenu.Item>
|
||||
* <AxoDropdownMenu.Separator />
|
||||
* <AxoDropdownMenu.Item symbol="trash" onSelect={onDelete}>Delete</AxoDropdownMenu.Item>
|
||||
* </AxoDropdownMenu.Content>
|
||||
* </AxoDropdownMenu.Root>
|
||||
* ```
|
||||
*
|
||||
* @see {@link https://www.radix-ui.com/primitives/docs/components/dropdown-menu | Dropdown Menu - Radix Docs}
|
||||
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/ | Menu Button Pattern - ARIA Authoring Practices Guide}
|
||||
* @see {@link https://w3c.github.io/aria/#button | `button` role - WAI-ARIA 1.3}
|
||||
* @see {@link https://w3c.github.io/aria/#menu | `menu` role - WAI-ARIA 1.3}
|
||||
*/
|
||||
export namespace AxoDropdownMenu {
|
||||
/**
|
||||
* <AxoDropdownMenu.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* The preferred alignment against the trigger.
|
||||
* May change when collisions occur.
|
||||
*/
|
||||
export type Align = AxoBaseMenu.Align;
|
||||
|
||||
/**
|
||||
* The preferred side of the trigger to render against when open.
|
||||
* Will be reversed when collisions occur.
|
||||
*/
|
||||
export type Side = AxoBaseMenu.Side;
|
||||
|
||||
/** @internal */
|
||||
type RootContextType = Readonly<{
|
||||
open: boolean;
|
||||
}>;
|
||||
|
||||
/** @internal */
|
||||
const RootContext = createStrictContext<RootContextType>(
|
||||
`${Namespace}.RootContext`
|
||||
'AxoDropdownMenu.RootContext'
|
||||
);
|
||||
|
||||
/**
|
||||
* Component: <AxoDropdownMenu.Root>
|
||||
* ---------------------------------
|
||||
*/
|
||||
|
||||
export type RootProps = AxoBaseMenu.MenuRootProps &
|
||||
Readonly<{
|
||||
/**
|
||||
* The modality of the dropdown menu. When set to `true`, interaction
|
||||
* with outside elements will be disabled and only menu content will be
|
||||
* visible to screen readers.
|
||||
* Defaults to `true`.
|
||||
*/
|
||||
modal?: boolean;
|
||||
/**
|
||||
* The controlled open state of the dropdown menu.
|
||||
* Must be used in conjunction with `onOpenChange`.
|
||||
*/
|
||||
open?: boolean;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Contains all the parts of a dropdown menu.
|
||||
*
|
||||
* @example Controlled open state
|
||||
* ```tsx
|
||||
* <AxoDropdownMenu.Root open={open} onOpenChange={setOpen}>
|
||||
* <AxoDropdownMenu.Trigger>
|
||||
* <AxoIconButton.Root variant="secondary" size="sm" symbol="more" label="More options" />
|
||||
* </AxoDropdownMenu.Trigger>
|
||||
* <AxoDropdownMenu.Content>
|
||||
* <AxoDropdownMenu.Item symbol="bell-slash" onSelect={onMute}>Mute notifications</AxoDropdownMenu.Item>
|
||||
* </AxoDropdownMenu.Content>
|
||||
* </AxoDropdownMenu.Root>
|
||||
* ```
|
||||
*/
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
const { modal, onOpenChange } = props;
|
||||
@@ -140,17 +156,15 @@ export namespace AxoDropdownMenu {
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = `${Namespace}.Root`;
|
||||
Root.displayName = 'AxoDropdownMenu.Root';
|
||||
|
||||
/**
|
||||
* Component: <AxoDropdownMenu.Trigger>
|
||||
* ------------------------------------
|
||||
* <AxoDropdownMenu.Trigger>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type TriggerProps = AxoBaseMenu.MenuTriggerProps;
|
||||
|
||||
const triggerDisplayName = `${Namespace}.Trigger`;
|
||||
|
||||
/**
|
||||
* The button that toggles the dropdown menu.
|
||||
* By default, the {@link AxoDropdownMenu.Content} will position itself
|
||||
@@ -164,15 +178,15 @@ export namespace AxoDropdownMenu {
|
||||
if (isTestOrMockEnvironment()) {
|
||||
assert(
|
||||
ref.current instanceof HTMLElement,
|
||||
`${triggerDisplayName} child must forward ref`
|
||||
'<AxoDropdownMenu.Trigger> child must forward ref'
|
||||
);
|
||||
assert(
|
||||
isAriaWidgetRole(getElementAriaRole(ref.current)),
|
||||
`${triggerDisplayName} child must have a widget role like 'button'`
|
||||
"<AxoDropdownMenu.Trigger> child must have a widget role like 'button'"
|
||||
);
|
||||
assert(
|
||||
computeAccessibleName(ref.current) !== '',
|
||||
`${triggerDisplayName} child must have an accessible name`
|
||||
'<AxoDropdownMenu.Trigger> child must have an accessible name'
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -190,11 +204,11 @@ export namespace AxoDropdownMenu {
|
||||
);
|
||||
});
|
||||
|
||||
Trigger.displayName = triggerDisplayName;
|
||||
Trigger.displayName = 'AxoDropdownMenu.Trigger';
|
||||
|
||||
/**
|
||||
* Component: <AxoDropdownMenu.Content>
|
||||
* ------------------------------------
|
||||
* <AxoDropdownMenu.Content>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ContentProps = AxoBaseMenu.MenuContentProps;
|
||||
@@ -229,11 +243,11 @@ export namespace AxoDropdownMenu {
|
||||
);
|
||||
});
|
||||
|
||||
Content.displayName = `${Namespace}.Content`;
|
||||
Content.displayName = 'AxoDropdownMenu.Content';
|
||||
|
||||
/**
|
||||
* Component: <AxoDropdownMenu.CustomItem>
|
||||
* -------------------------------------
|
||||
* <AxoDropdownMenu.CustomItem>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type CustomItemProps = Pick<
|
||||
@@ -241,13 +255,27 @@ export namespace AxoDropdownMenu {
|
||||
'disabled' | 'textValue' | 'keyboardShortcut' | 'onSelect'
|
||||
> &
|
||||
Readonly<{
|
||||
/**
|
||||
* Content of the "leading" slot, will be used to align with other
|
||||
* leading items.
|
||||
*/
|
||||
leading?: ReactNode;
|
||||
// trailing?: ReactNode;
|
||||
/** The primary label text of the item. */
|
||||
text: ReactNode;
|
||||
// prefix?: ReactNode;
|
||||
/**
|
||||
* Content appended after the label in the content slot
|
||||
* (e.g. a count badge or status indicator).
|
||||
*/
|
||||
suffix?: ReactNode;
|
||||
|
||||
// TODO(jamie): trailing?: ReactNode;
|
||||
// TODO(jamie): prefix?: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A menu item with a fully customizable leading slot and content slot.
|
||||
* Use when the built-in `Item` doesn't cover your layout needs.
|
||||
*/
|
||||
export const CustomItem: FC<CustomItemProps> = memo(props => {
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
@@ -269,23 +297,17 @@ export namespace AxoDropdownMenu {
|
||||
);
|
||||
});
|
||||
|
||||
CustomItem.displayName = `${Namespace}.CustomItem`;
|
||||
CustomItem.displayName = 'AxoDropdownMenu.CustomItem';
|
||||
|
||||
/**
|
||||
* Component: <AxoDropdownMenu.Item>
|
||||
* ---------------------------------
|
||||
* <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>
|
||||
* ````
|
||||
* A single selectable row in the dropdown menu.
|
||||
*/
|
||||
export const Item: FC<ItemProps> = memo(props => {
|
||||
return (
|
||||
@@ -312,17 +334,17 @@ export namespace AxoDropdownMenu {
|
||||
);
|
||||
});
|
||||
|
||||
Item.displayName = `${Namespace}.Item`;
|
||||
Item.displayName = 'AxoDropdownMenu.Item';
|
||||
|
||||
/**
|
||||
* Component: <AxoDropdownMenu.Group>
|
||||
* ----------------------------------
|
||||
* <AxoDropdownMenu.Group>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type GroupProps = AxoBaseMenu.MenuGroupProps;
|
||||
|
||||
/**
|
||||
* Used to group multiple {@link AxoDropdownMenu.Item}'s.
|
||||
* Group multiple {@link AxoDropdownMenu.Item}'s.
|
||||
*/
|
||||
export const Group: FC<GroupProps> = memo(props => {
|
||||
return (
|
||||
@@ -332,17 +354,17 @@ export namespace AxoDropdownMenu {
|
||||
);
|
||||
});
|
||||
|
||||
Group.displayName = `${Namespace}.Group`;
|
||||
Group.displayName = 'AxoDropdownMenu.Group';
|
||||
|
||||
/**
|
||||
* Component: <AxoDropdownMenu.Label>
|
||||
* ----------------------------------
|
||||
* <AxoDropdownMenu.Label>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type LabelProps = AxoBaseMenu.MenuLabelProps;
|
||||
|
||||
/**
|
||||
* Used to render a label. It won't be focusable using arrow keys.
|
||||
* Render a label. It won't be focusable using arrow keys.
|
||||
*/
|
||||
export const Label: FC<LabelProps> = memo(props => {
|
||||
return (
|
||||
@@ -354,24 +376,31 @@ export namespace AxoDropdownMenu {
|
||||
);
|
||||
});
|
||||
|
||||
Label.displayName = `${Namespace}.Label`;
|
||||
Label.displayName = 'AxoDropdownMenu.Label';
|
||||
|
||||
/**
|
||||
* Component: <AxoDropdownMenu.Header>
|
||||
* -----------------------------------
|
||||
* <AxoDropdownMenu.Header>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type HeaderProps = Readonly<{
|
||||
/** The primary title shown at the top of the content panel. */
|
||||
label: ReactNode;
|
||||
/** Optional secondary text below the title. */
|
||||
description?: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A non-interactive header at the top of a `Content` or `SubContent` panel.
|
||||
* Automatically registers itself as the accessible label and description
|
||||
* for the containing menu.
|
||||
*/
|
||||
export const Header: FC<HeaderProps> = memo(props => {
|
||||
const labelId = useId();
|
||||
const descriptionId = useId();
|
||||
|
||||
const { labelRef, descriptionRef } = useAriaLabellingContext(
|
||||
`${Namespace}.Content/SubContent`
|
||||
'AxoDropdownMenu.Content/SubContent'
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -396,11 +425,11 @@ export namespace AxoDropdownMenu {
|
||||
);
|
||||
});
|
||||
|
||||
Header.displayName = `${Namespace}.Header`;
|
||||
Header.displayName = 'AxoDropdownMenu.Header';
|
||||
|
||||
/**
|
||||
* Component: <AxoDropdownMenu.CheckboxItem>
|
||||
* -----------------------------------------
|
||||
* <AxoDropdownMenu.CheckboxItem>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type CheckboxItemProps = AxoBaseMenu.MenuCheckboxItemProps;
|
||||
@@ -442,17 +471,17 @@ export namespace AxoDropdownMenu {
|
||||
);
|
||||
});
|
||||
|
||||
CheckboxItem.displayName = `${Namespace}.CheckboxItem`;
|
||||
CheckboxItem.displayName = 'AxoDropdownMenu.CheckboxItem';
|
||||
|
||||
/**
|
||||
* Component: <AxoDropdownMenu.RadioGroup>
|
||||
* ---------------------------------------
|
||||
* <AxoDropdownMenu.RadioGroup>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type RadioGroupProps = AxoBaseMenu.MenuRadioGroupProps;
|
||||
|
||||
/**
|
||||
* Used to group multiple {@link AxoDropdownMenu.RadioItem}'s.
|
||||
* Group multiple {@link AxoDropdownMenu.RadioItem}'s.
|
||||
*/
|
||||
export const RadioGroup: FC<RadioGroupProps> = memo(props => {
|
||||
return (
|
||||
@@ -466,11 +495,11 @@ export namespace AxoDropdownMenu {
|
||||
);
|
||||
});
|
||||
|
||||
RadioGroup.displayName = `${Namespace}.RadioGroup`;
|
||||
RadioGroup.displayName = 'AxoDropdownMenu.RadioGroup';
|
||||
|
||||
/**
|
||||
* Component: <AxoDropdownMenu.RadioItem>
|
||||
* --------------------------------------
|
||||
* <AxoDropdownMenu.RadioItem>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type RadioItemProps = AxoBaseMenu.MenuRadioItemProps;
|
||||
@@ -511,15 +540,15 @@ export namespace AxoDropdownMenu {
|
||||
);
|
||||
});
|
||||
|
||||
RadioItem.displayName = `${Namespace}.RadioItem`;
|
||||
RadioItem.displayName = 'AxoDropdownMenu.RadioItem';
|
||||
|
||||
/**
|
||||
* Component: <AxoDropdownMenu.Separator>
|
||||
* --------------------------------------
|
||||
* <AxoDropdownMenu.Separator>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Used to visually separate items in the dropdown menu.
|
||||
* Visually separate items in the dropdown menu.
|
||||
*/
|
||||
export const Separator: FC = memo(() => {
|
||||
return (
|
||||
@@ -527,12 +556,17 @@ export namespace AxoDropdownMenu {
|
||||
);
|
||||
});
|
||||
|
||||
Separator.displayName = `${Namespace}.Separator`;
|
||||
Separator.displayName = 'AxoDropdownMenu.Separator';
|
||||
|
||||
/**
|
||||
* Component: <AxoDropdownMenu.ContentSeparator>
|
||||
* <AxoDropdownMenu.ContentSeparator>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Like `Separator`, but spans only the content column rather than the full
|
||||
* row.
|
||||
*/
|
||||
export const ContentSeparator: FC = memo(() => {
|
||||
return (
|
||||
<DropdownMenu.Separator
|
||||
@@ -541,11 +575,11 @@ export namespace AxoDropdownMenu {
|
||||
);
|
||||
});
|
||||
|
||||
ContentSeparator.displayName = `${Namespace}.ContentSeparator`;
|
||||
ContentSeparator.displayName = 'AxoDropdownMenu.ContentSeparator';
|
||||
|
||||
/**
|
||||
* Component: <AxoDropdownMenu.Sub>
|
||||
* -------------------------------
|
||||
* <AxoDropdownMenu.Sub>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type SubProps = AxoBaseMenu.MenuSubProps;
|
||||
@@ -557,18 +591,17 @@ export namespace AxoDropdownMenu {
|
||||
return <DropdownMenu.Sub>{props.children}</DropdownMenu.Sub>;
|
||||
});
|
||||
|
||||
Sub.displayName = `${Namespace}.Sub`;
|
||||
Sub.displayName = 'AxoDropdownMenu.Sub';
|
||||
|
||||
/**
|
||||
* Component: <AxoDropdownMenu.SubTrigger>
|
||||
* ---------------------------------------
|
||||
* <AxoDropdownMenu.SubTrigger>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type SubTriggerProps = AxoBaseMenu.MenuSubTriggerProps;
|
||||
|
||||
/**
|
||||
* An item that opens a submenu. Must be rendered inside
|
||||
* {@link ContextMenu.Sub}.
|
||||
* An item that opens a submenu. Must be rendered inside {@link AxoDropdownMenu.Sub}.
|
||||
*/
|
||||
export const SubTrigger: FC<SubTriggerProps> = memo(props => {
|
||||
return (
|
||||
@@ -592,11 +625,11 @@ export namespace AxoDropdownMenu {
|
||||
);
|
||||
});
|
||||
|
||||
SubTrigger.displayName = `${Namespace}.SubTrigger`;
|
||||
SubTrigger.displayName = 'AxoDropdownMenu.SubTrigger';
|
||||
|
||||
/**
|
||||
* Component: <AxoDropdownMenu.SubContent>
|
||||
* ---------------------------------------
|
||||
* <AxoDropdownMenu.SubContent>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type SubContentProps = AxoBaseMenu.MenuSubContentProps;
|
||||
@@ -622,5 +655,5 @@ export namespace AxoDropdownMenu {
|
||||
);
|
||||
});
|
||||
|
||||
SubContent.displayName = `${Namespace}.SubContent`;
|
||||
SubContent.displayName = 'AxoDropdownMenu.SubContent';
|
||||
}
|
||||
|
||||
@@ -135,8 +135,8 @@ export function Sizes(): JSX.Element {
|
||||
|
||||
const AllStates: Record<string, Partial<AxoIconButton.RootProps>> = {
|
||||
'disabled=true': { disabled: true },
|
||||
'aria-pressed=false': { 'aria-pressed': false },
|
||||
'aria-pressed=true': { 'aria-pressed': true },
|
||||
'pressed=false': { pressed: false },
|
||||
'pressed=true': { pressed: true },
|
||||
};
|
||||
|
||||
export function States(): JSX.Element {
|
||||
@@ -211,7 +211,7 @@ export function Spinners(): JSX.Element {
|
||||
size={size}
|
||||
symbol="more"
|
||||
label="More actions"
|
||||
experimentalSpinner={{ 'aria-label': 'Loading' }}
|
||||
pending
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+239
-141
@@ -1,99 +1,117 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ButtonHTMLAttributes, FC, ForwardedRef, JSX } from 'react';
|
||||
import { forwardRef, memo, useMemo } from 'react';
|
||||
import type { FC, Ref, MouseEvent } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { AxoSymbol } from './AxoSymbol.dom.tsx';
|
||||
import type { TailwindStyles } from './tw.dom.tsx';
|
||||
import { tw } from './tw.dom.tsx';
|
||||
import type { SpinnerVariant } from '../components/SpinnerV2.dom.tsx';
|
||||
import { SpinnerV2 } from '../components/SpinnerV2.dom.tsx';
|
||||
import { AxoTooltip } from './AxoTooltip.dom.tsx';
|
||||
import { useAxoIntl } from './_internal/AxoIntl.dom.tsx';
|
||||
import { variants } from './_internal/variants.dom.tsx';
|
||||
|
||||
const Namespace = 'AxoIconButton';
|
||||
|
||||
type GenericButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
/**
|
||||
* A circular icon-only button with an accessible label and built-in tooltip.
|
||||
*
|
||||
* @example Anatomy
|
||||
* ```tsx
|
||||
* <AxoIconButton.Root />
|
||||
* ```
|
||||
*
|
||||
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/button/ | Button Pattern - ARIA Authoring Practices Guide}
|
||||
* @see {@link https://w3c.github.io/aria/#button | `button` role - WAI-ARIA 1.3}
|
||||
*/
|
||||
export namespace AxoIconButton {
|
||||
/**
|
||||
* <AxoIconButton.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const baseStyles = tw(
|
||||
'relative rounded-full leading-none select-none',
|
||||
'not-forced-colors:outline-none keyboard-mode:focus:outline-focus-ring',
|
||||
'forced-colors:border forced-colors:border-[ButtonBorder] forced-colors:bg-[ButtonFace] forced-colors:text-[ButtonText]',
|
||||
'forced-colors:disabled:text-[GrayText]',
|
||||
'forced-colors:aria-disabled:text-[GrayText]',
|
||||
'forced-colors:aria-pressed:bg-[SelectedItem] forced-colors:aria-pressed:text-[SelectedItemText]'
|
||||
);
|
||||
|
||||
const pressedStyles = {
|
||||
fillInverted: tw(
|
||||
'aria-pressed:bg-fill-inverted aria-pressed:enabled:active:bg-fill-inverted-pressed',
|
||||
'aria-pressed:text-label-primary-inverted aria-pressed:disabled:text-label-disabled-inverted'
|
||||
),
|
||||
colorFillPrimary: tw(
|
||||
'aria-pressed:bg-color-fill-primary aria-pressed:enabled:active:bg-color-fill-primary-pressed',
|
||||
'aria-pressed:text-label-primary-on-color aria-pressed:disabled:text-label-disabled-on-color'
|
||||
),
|
||||
};
|
||||
const pressedInvertedStyles = tw(
|
||||
'aria-pressed:bg-fill-inverted aria-pressed:not-aria-disabled:active:bg-fill-inverted-pressed',
|
||||
'aria-pressed:text-label-primary-inverted aria-pressed:aria-disabled:text-label-disabled-inverted'
|
||||
);
|
||||
|
||||
const Variants: Record<Variant, TailwindStyles> = {
|
||||
const pressedPrimaryStyles = tw(
|
||||
'aria-pressed:bg-color-fill-primary aria-pressed:not-aria-disabled:active:bg-color-fill-primary-pressed',
|
||||
'aria-pressed:text-label-primary-on-color aria-pressed:aria-disabled:text-label-disabled-on-color'
|
||||
);
|
||||
|
||||
const Variants = variants<Variant>('AxoIconButton.Variant', {
|
||||
secondary: tw(
|
||||
'bg-fill-secondary enabled:active:bg-fill-secondary-pressed',
|
||||
'bg-fill-secondary not-aria-disabled:active:bg-fill-secondary-pressed',
|
||||
'data-[axo-dropdownmenu-state=open]:bg-fill-secondary-pressed',
|
||||
'text-label-primary disabled:text-label-disabled',
|
||||
pressedStyles.fillInverted
|
||||
'text-label-primary aria-disabled:text-label-disabled',
|
||||
pressedInvertedStyles
|
||||
),
|
||||
primary: tw(
|
||||
'bg-color-fill-primary enabled:active:bg-color-fill-primary-pressed',
|
||||
'bg-color-fill-primary not-aria-disabled:active:bg-color-fill-primary-pressed',
|
||||
'data-[axo-dropdownmenu-state=open]:bg-color-fill-primary-pressed',
|
||||
'text-label-primary-on-color disabled:text-label-disabled-on-color',
|
||||
pressedStyles.fillInverted
|
||||
'text-label-primary-on-color aria-disabled:text-label-disabled-on-color',
|
||||
pressedInvertedStyles
|
||||
),
|
||||
affirmative: tw(
|
||||
'bg-color-fill-affirmative enabled:active:bg-color-fill-affirmative-pressed',
|
||||
'bg-color-fill-affirmative not-aria-disabled:active:bg-color-fill-affirmative-pressed',
|
||||
'data-[axo-dropdownmenu-state=open]:bg-color-fill-affirmative-pressed',
|
||||
'text-label-primary-on-color disabled:text-label-disabled-on-color',
|
||||
pressedStyles.fillInverted
|
||||
'text-label-primary-on-color aria-disabled:text-label-disabled-on-color',
|
||||
pressedInvertedStyles
|
||||
),
|
||||
destructive: tw(
|
||||
'bg-color-fill-destructive enabled:active:bg-color-fill-destructive-pressed',
|
||||
'bg-color-fill-destructive not-aria-disabled:active:bg-color-fill-destructive-pressed',
|
||||
'data-[axo-dropdownmenu-state=open]:bg-color-fill-destructive-pressed',
|
||||
'text-label-primary-on-color disabled:text-label-disabled-on-color',
|
||||
pressedStyles.fillInverted
|
||||
'text-label-primary-on-color aria-disabled:text-label-disabled-on-color',
|
||||
pressedInvertedStyles
|
||||
),
|
||||
'borderless-secondary': tw(
|
||||
'enabled:hover:not-aria-pressed:bg-fill-secondary enabled:active:bg-fill-secondary-pressed',
|
||||
'not-aria-disabled:hover:not-aria-pressed:bg-fill-secondary not-aria-disabled:active:bg-fill-secondary-pressed',
|
||||
'focus:bg-fill-secondary',
|
||||
'data-[axo-dropdownmenu-state=open]:bg-fill-secondary-pressed',
|
||||
'text-label-primary disabled:text-label-disabled',
|
||||
pressedStyles.colorFillPrimary
|
||||
'text-label-primary aria-disabled:text-label-disabled',
|
||||
pressedPrimaryStyles
|
||||
),
|
||||
'floating-secondary': tw(
|
||||
'bg-fill-floating enabled:active:bg-fill-floating-pressed',
|
||||
'bg-fill-floating not-aria-disabled:active:bg-fill-floating-pressed',
|
||||
'data-[axo-dropdownmenu-state=open]:bg-fill-floating-pressed',
|
||||
'text-label-primary disabled:text-label-disabled',
|
||||
'text-label-primary aria-disabled:text-label-disabled',
|
||||
'shadow-elevation-1',
|
||||
pressedStyles.fillInverted
|
||||
pressedInvertedStyles
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
/** @testexport */
|
||||
export function _getAllVariants(): ReadonlyArray<Variant> {
|
||||
return Object.keys(Variants) as ReadonlyArray<Variant>;
|
||||
return Variants.keys();
|
||||
}
|
||||
|
||||
type SizeConfig = Readonly<{
|
||||
buttonStyles: TailwindStyles;
|
||||
iconSize: AxoSymbol.IconSize;
|
||||
}>;
|
||||
const Sizes = variants<Size>('AxoIconButton.Size', {
|
||||
sm: tw('p-[5px]'),
|
||||
md: tw('p-1.5'),
|
||||
lg: tw('p-2'),
|
||||
});
|
||||
|
||||
const Sizes: Record<Size, SizeConfig> = {
|
||||
sm: { buttonStyles: tw('p-[5px]'), iconSize: 18 },
|
||||
md: { buttonStyles: tw('p-1.5'), iconSize: 20 },
|
||||
lg: { buttonStyles: tw('p-2'), iconSize: 20 },
|
||||
};
|
||||
const IconSizes = variants<Size, AxoSymbol.IconSize>('AxoIconButton.Size', {
|
||||
sm: 18,
|
||||
md: 20,
|
||||
lg: 20,
|
||||
});
|
||||
|
||||
/** @testexport */
|
||||
export function _getAllSizes(): ReadonlyArray<Size> {
|
||||
return Object.keys(Sizes) as ReadonlyArray<Size>;
|
||||
return Sizes.keys();
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual style of the button.
|
||||
*/
|
||||
export type Variant =
|
||||
| 'secondary'
|
||||
| 'primary'
|
||||
@@ -102,128 +120,207 @@ export namespace AxoIconButton {
|
||||
| 'borderless-secondary'
|
||||
| 'floating-secondary';
|
||||
|
||||
/**
|
||||
* Size of the button.
|
||||
*/
|
||||
export type Size = 'sm' | 'md' | 'lg';
|
||||
|
||||
export type RootProps = Readonly<{
|
||||
// required: Should describe the purpose of the button, not the icon.
|
||||
/**
|
||||
* Ref to the underlying `<button>` element.
|
||||
*/
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
/**
|
||||
* Accessible label for the button. Should describe the action, not the icon.
|
||||
* Also used as the default tooltip text.
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* Tooltip shown on hover.
|
||||
* - `true` (default): uses `label` as the tooltip text.
|
||||
* - `false`: no tooltip.
|
||||
* - `AxoTooltip.RootConfigProps`: custom tooltip configuration.
|
||||
*/
|
||||
tooltip?: boolean | AxoTooltip.RootConfigProps;
|
||||
|
||||
/**
|
||||
* Visual style of the button.
|
||||
*/
|
||||
variant: Variant;
|
||||
/**
|
||||
* Size of the button.
|
||||
*/
|
||||
size: Size;
|
||||
/**
|
||||
* Icon to display inside the button.
|
||||
*/
|
||||
symbol: AxoSymbol.IconName;
|
||||
/**
|
||||
* Stroke weight override for the icon.
|
||||
*/
|
||||
iconWeight?: AxoSymbol.Weight;
|
||||
|
||||
experimentalSpinner?: { 'aria-label': string } | null;
|
||||
|
||||
disabled?: GenericButtonProps['disabled'];
|
||||
onClick?: GenericButtonProps['onClick'];
|
||||
'aria-pressed'?: GenericButtonProps['aria-pressed'];
|
||||
// Note: Technically we forward all props for Radix, but we restrict the
|
||||
// props that the type accepts
|
||||
/**
|
||||
* When `true`, shows a spinner and prevents interaction.
|
||||
*/
|
||||
pending?: boolean | null;
|
||||
/**
|
||||
* When set, the button behaves as a toggle with `aria-pressed` semantics.
|
||||
*/
|
||||
pressed?: boolean | null;
|
||||
/**
|
||||
* When `true`, prevents interaction.
|
||||
*/
|
||||
disabled?: boolean | null;
|
||||
/**
|
||||
* Called when the button is clicked. Not called when `pending` or `disabled`.
|
||||
*/
|
||||
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
}>;
|
||||
|
||||
export const Root: FC<RootProps> = memo(
|
||||
forwardRef((props, ref: ForwardedRef<HTMLButtonElement>) => {
|
||||
const {
|
||||
label,
|
||||
tooltip = true,
|
||||
variant,
|
||||
size,
|
||||
symbol,
|
||||
iconWeight,
|
||||
experimentalSpinner,
|
||||
...rest
|
||||
} = props;
|
||||
/**
|
||||
* A circular icon-only button.
|
||||
* Wraps in a tooltip by default using `label`.
|
||||
*
|
||||
* @example Close button
|
||||
* ```tsx
|
||||
* <AxoIconButton.Root
|
||||
* label="Close"
|
||||
* variant="borderless-secondary"
|
||||
* size="md"
|
||||
* symbol="x"
|
||||
* onClick={onClose}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example Toggle mute button
|
||||
* ```tsx
|
||||
* <AxoIconButton.Root
|
||||
* label={muted ? 'Unmute' : 'Mute'}
|
||||
* variant="borderless-secondary"
|
||||
* size="md"
|
||||
* symbol={muted ? 'mic-slash' : 'mic'}
|
||||
* pressed={muted}
|
||||
* onClick={toggleMuted}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
const {
|
||||
label,
|
||||
tooltip = true,
|
||||
variant,
|
||||
size,
|
||||
symbol,
|
||||
iconWeight,
|
||||
pending,
|
||||
pressed,
|
||||
disabled,
|
||||
onClick,
|
||||
...rest
|
||||
} = props;
|
||||
const intl = useAxoIntl();
|
||||
|
||||
const tooltipConfig = useMemo(() => {
|
||||
if (!tooltip) {
|
||||
return null;
|
||||
}
|
||||
if (typeof tooltip === 'object') {
|
||||
return tooltip;
|
||||
}
|
||||
return { label };
|
||||
}, [tooltip, label]);
|
||||
const tooltipConfig = useMemo(() => {
|
||||
if (!tooltip) {
|
||||
return null;
|
||||
}
|
||||
if (typeof tooltip === 'object') {
|
||||
return tooltip;
|
||||
}
|
||||
return { label };
|
||||
}, [tooltip, label]);
|
||||
|
||||
const button = (
|
||||
<button
|
||||
ref={ref}
|
||||
{...rest}
|
||||
aria-label={label}
|
||||
type="button"
|
||||
const handleClick = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
if (pending || disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onClick?.(event);
|
||||
},
|
||||
[pending, disabled, onClick]
|
||||
);
|
||||
|
||||
const button = (
|
||||
<button
|
||||
ref={props.ref}
|
||||
type="button"
|
||||
aria-label={pending ? intl.get('AxoButton.Pending') : label}
|
||||
aria-pressed={pressed ?? undefined}
|
||||
aria-disabled={(pending || disabled) ?? undefined}
|
||||
onClick={handleClick}
|
||||
className={tw(baseStyles, Variants.get(variant), Sizes.get(size))}
|
||||
{...rest}
|
||||
>
|
||||
<span
|
||||
aria-hidden={pending ?? undefined}
|
||||
className={tw(
|
||||
baseStyles,
|
||||
Variants[variant],
|
||||
Sizes[size].buttonStyles
|
||||
'align-top forced-color-adjust-none',
|
||||
pending ? 'opacity-0' : null
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={tw(
|
||||
'align-top forced-color-adjust-none',
|
||||
experimentalSpinner != null ? 'opacity-0' : null
|
||||
)}
|
||||
>
|
||||
<AxoSymbol.Icon
|
||||
size={Sizes[size].iconSize}
|
||||
symbol={symbol}
|
||||
label={null}
|
||||
weight={iconWeight}
|
||||
/>
|
||||
</span>
|
||||
{experimentalSpinner != null && (
|
||||
<Spinner
|
||||
buttonVariant={variant}
|
||||
buttonSize={size}
|
||||
aria-label={experimentalSpinner['aria-label']}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
<AxoSymbol.Icon
|
||||
size={IconSizes.get(size)}
|
||||
symbol={symbol}
|
||||
label={null}
|
||||
weight={iconWeight}
|
||||
/>
|
||||
</span>
|
||||
{pending && <Spinner buttonVariant={variant} buttonSize={size} />}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (tooltipConfig != null) {
|
||||
return (
|
||||
<AxoTooltip.Root
|
||||
{...tooltipConfig}
|
||||
tooltipRepeatsTriggerAccessibleName={label === tooltipConfig.label}
|
||||
>
|
||||
{button}
|
||||
</AxoTooltip.Root>
|
||||
);
|
||||
}
|
||||
|
||||
if (tooltipConfig != null) {
|
||||
return (
|
||||
<AxoTooltip.Root
|
||||
{...tooltipConfig}
|
||||
tooltipRepeatsTriggerAccessibleName={label === tooltipConfig.label}
|
||||
>
|
||||
{button}
|
||||
</AxoTooltip.Root>
|
||||
);
|
||||
}
|
||||
return button;
|
||||
});
|
||||
|
||||
return button;
|
||||
})
|
||||
Root.displayName = 'AxoIconButton.Root';
|
||||
|
||||
/**
|
||||
* <AxoIconButton.Spinner>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const SpinnerVariants = variants<Variant, SpinnerVariant>(
|
||||
'AxoIconButton.Variant',
|
||||
{
|
||||
primary: 'axo-button-spinner-on-color',
|
||||
secondary: 'axo-button-spinner-secondary',
|
||||
affirmative: 'axo-button-spinner-on-color',
|
||||
destructive: 'axo-button-spinner-on-color',
|
||||
'floating-secondary': 'axo-button-spinner-secondary',
|
||||
'borderless-secondary': 'axo-button-spinner-secondary',
|
||||
}
|
||||
);
|
||||
|
||||
Root.displayName = `${Namespace}.Root`;
|
||||
|
||||
const SpinnerVariants: Record<Variant, SpinnerVariant> = {
|
||||
primary: 'axo-button-spinner-on-color',
|
||||
secondary: 'axo-button-spinner-secondary',
|
||||
affirmative: 'axo-button-spinner-on-color',
|
||||
destructive: 'axo-button-spinner-on-color',
|
||||
'floating-secondary': 'axo-button-spinner-secondary',
|
||||
'borderless-secondary': 'axo-button-spinner-secondary',
|
||||
};
|
||||
|
||||
type SpinnerSizeConfig = { size: number; strokeWidth: number };
|
||||
|
||||
const SpinnerSizes: Record<Size, SpinnerSizeConfig> = {
|
||||
const SpinnerSizes = variants<Size, SpinnerSizeConfig>('AxoIconButton.Size', {
|
||||
lg: { size: 20, strokeWidth: 2 },
|
||||
md: { size: 20, strokeWidth: 2 },
|
||||
sm: { size: 16, strokeWidth: 1.5 },
|
||||
};
|
||||
});
|
||||
|
||||
/** @internal */
|
||||
type SpinnerProps = Readonly<{
|
||||
buttonVariant: Variant;
|
||||
buttonSize: Size;
|
||||
'aria-label': string;
|
||||
}>;
|
||||
|
||||
function Spinner(props: SpinnerProps): JSX.Element {
|
||||
const variant = SpinnerVariants[props.buttonVariant];
|
||||
const sizeConfig = SpinnerSizes[props.buttonSize];
|
||||
/** @internal */
|
||||
const Spinner: FC<SpinnerProps> = memo(props => {
|
||||
const variant = SpinnerVariants.get(props.buttonVariant);
|
||||
const sizeConfig = SpinnerSizes.get(props.buttonSize);
|
||||
return (
|
||||
<span className={tw('absolute inset-0 flex items-center justify-center')}>
|
||||
<SpinnerV2
|
||||
@@ -231,9 +328,10 @@ export namespace AxoIconButton {
|
||||
strokeWidth={sizeConfig.strokeWidth}
|
||||
variant={variant}
|
||||
value="indeterminate"
|
||||
ariaLabel={props['aria-label']}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Spinner.displayName = 'AxoIconButton.Spinner';
|
||||
}
|
||||
|
||||
+212
-30
@@ -12,21 +12,95 @@ import {
|
||||
useStrictContext,
|
||||
} from './_internal/StrictContext.dom.tsx';
|
||||
|
||||
const Namespace = 'AxoMenuBuilder';
|
||||
|
||||
/**
|
||||
* Create a menu that can either be a dropdown menu or context menu by passing
|
||||
* a `renderer` prop.
|
||||
*
|
||||
* Use this when the same menu structure needs to work as both an
|
||||
* `AxoDropdownMenu` and an `AxoContextMenu`.
|
||||
*
|
||||
* @example Anatomy
|
||||
* ```tsx
|
||||
* <AxoMenuBuilder.Root renderer={renderer}>
|
||||
* <AxoMenuBuilder.Trigger />
|
||||
* <AxoMenuBuilder.Content>
|
||||
* <AxoMenuBuilder.Item />
|
||||
* <AxoMenuBuilder.Separator />
|
||||
* <AxoMenuBuilder.CheckboxItem />
|
||||
* <AxoMenuBuilder.RadioGroup>
|
||||
* <AxoMenuBuilder.RadioItem />
|
||||
* </AxoMenuBuilder.RadioGroup>
|
||||
* <AxoMenuBuilder.Sub>
|
||||
* <AxoMenuBuilder.SubTrigger />
|
||||
* <AxoMenuBuilder.SubContent />
|
||||
* </AxoMenuBuilder.Sub>
|
||||
* </AxoMenuBuilder.Content>
|
||||
* </AxoMenuBuilder.Root>
|
||||
* ```
|
||||
*
|
||||
* @see {AxoDropdownMenu}
|
||||
* @see {AxoContextMenu}
|
||||
* @see {@link https://www.radix-ui.com/primitives/docs/components/dropdown-menu | Dropdown Menu - Radix Docs}
|
||||
* @see {@link https://www.radix-ui.com/primitives/docs/components/context-menu | Context Menu - Radix Docs}
|
||||
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/ | Menu Button Pattern - ARIA Authoring Practices Guide}
|
||||
* @see {@link https://w3c.github.io/aria/#button | `button` role - WAI-ARIA 1.3}
|
||||
* @see {@link https://w3c.github.io/aria/#menu | `menu` role - WAI-ARIA 1.3}
|
||||
*/
|
||||
export namespace AxoMenuBuilder {
|
||||
/**
|
||||
* <AxoMenuBuilder.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Which menu primitive renders the component tree.
|
||||
* - `AxoDropdownMenu`: Opened by clicking the `Trigger` element.
|
||||
* - `AxoContextMenu`: Opened by right-clicking the `Trigger` element.
|
||||
*/
|
||||
export type Renderer = 'AxoDropdownMenu' | 'AxoContextMenu';
|
||||
|
||||
/**
|
||||
* The horizontal alignment of the menu content relative to the trigger.
|
||||
*/
|
||||
export type Align = AxoBaseMenu.Align;
|
||||
|
||||
/**
|
||||
* The preferred side of the trigger to render the menu content against.
|
||||
*/
|
||||
export type Side = AxoBaseMenu.Side;
|
||||
|
||||
const MenuBuilderContext = createStrictContext<Renderer>(`${Namespace}.Root`);
|
||||
/** @internal */
|
||||
const RendererContext = createStrictContext<Renderer>('AxoMenuBuilder.Root');
|
||||
|
||||
/**
|
||||
* <AxoMenuBuilder.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type RootProps = AxoBaseMenu.MenuRootProps &
|
||||
Readonly<{
|
||||
/**
|
||||
* Which menu primitive to use.
|
||||
* Passed down to all child components via context.
|
||||
*/
|
||||
renderer: Renderer;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Container for the menu. Sets the `renderer` in context so all child
|
||||
* components delegate to the correct underlying primitive.
|
||||
*
|
||||
* @example Same menu as dropdown or context menu
|
||||
* ```tsx
|
||||
* <AxoMenuBuilder.Root renderer={renderer} onOpenChange={setOpen}>
|
||||
* <AxoMenuBuilder.Trigger>{children}</AxoMenuBuilder.Trigger>
|
||||
* <AxoMenuBuilder.Content>
|
||||
* <AxoMenuBuilder.Item symbol="reply" onSelect={onReply}>Reply</AxoMenuBuilder.Item>
|
||||
* <AxoMenuBuilder.Item symbol="copy" onSelect={onCopy}>Copy</AxoMenuBuilder.Item>
|
||||
* </AxoMenuBuilder.Content>
|
||||
* </AxoMenuBuilder.Root>
|
||||
* ```
|
||||
*/
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
const { renderer, ...rest } = props;
|
||||
|
||||
@@ -40,16 +114,25 @@ export namespace AxoMenuBuilder {
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuBuilderContext.Provider value={props.renderer}>
|
||||
<RendererContext.Provider value={props.renderer}>
|
||||
{child}
|
||||
</MenuBuilderContext.Provider>
|
||||
</RendererContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = `${Namespace}.Root`;
|
||||
Root.displayName = 'AxoMenuBuilder.Root';
|
||||
|
||||
/**
|
||||
* <AxoMenuBuilder.Trigger>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Delegates to `AxoDropdownMenu.Trigger` or `AxoContextMenu.Trigger` based
|
||||
* on the active renderer.
|
||||
*/
|
||||
export const Trigger: FC<AxoBaseMenu.MenuTriggerProps> = memo(props => {
|
||||
const renderer = useStrictContext(MenuBuilderContext);
|
||||
const renderer = useStrictContext(RendererContext);
|
||||
if (renderer === 'AxoDropdownMenu') {
|
||||
return <AxoDropdownMenu.Trigger {...props} />;
|
||||
}
|
||||
@@ -59,10 +142,19 @@ export namespace AxoMenuBuilder {
|
||||
unreachable(renderer);
|
||||
});
|
||||
|
||||
Trigger.displayName = `${Namespace}.Trigger`;
|
||||
Trigger.displayName = 'AxoMenuBuilder.Trigger';
|
||||
|
||||
/**
|
||||
* <AxoMenuBuilder.Content>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Delegates to `AxoDropdownMenu.Content` or `AxoContextMenu.Content` based
|
||||
* on the active renderer.
|
||||
*/
|
||||
export const Content: FC<AxoBaseMenu.MenuContentProps> = memo(props => {
|
||||
const renderer = useStrictContext(MenuBuilderContext);
|
||||
const renderer = useStrictContext(RendererContext);
|
||||
if (renderer === 'AxoDropdownMenu') {
|
||||
return <AxoDropdownMenu.Content {...props} />;
|
||||
}
|
||||
@@ -72,10 +164,19 @@ export namespace AxoMenuBuilder {
|
||||
unreachable(renderer);
|
||||
});
|
||||
|
||||
Content.displayName = `${Namespace}.Content`;
|
||||
Content.displayName = 'AxoMenuBuilder.Content';
|
||||
|
||||
/**
|
||||
* <AxoMenuBuilder.Item>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Delegates to `AxoDropdownMenu.Item` or `AxoContextMenu.Item` based on the
|
||||
* active renderer.
|
||||
*/
|
||||
export const Item: FC<AxoBaseMenu.MenuItemProps> = memo(props => {
|
||||
const renderer = useStrictContext(MenuBuilderContext);
|
||||
const renderer = useStrictContext(RendererContext);
|
||||
if (renderer === 'AxoDropdownMenu') {
|
||||
return <AxoDropdownMenu.Item {...props} />;
|
||||
}
|
||||
@@ -85,10 +186,19 @@ export namespace AxoMenuBuilder {
|
||||
unreachable(renderer);
|
||||
});
|
||||
|
||||
Item.displayName = `${Namespace}.Item`;
|
||||
Item.displayName = 'AxoMenuBuilder.Item';
|
||||
|
||||
/**
|
||||
* <AxoMenuBuilder.Group>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Delegates to `AxoDropdownMenu.Group` or `AxoContextMenu.Group` based on
|
||||
* the active renderer.
|
||||
*/
|
||||
export const Group: FC<AxoBaseMenu.MenuGroupProps> = memo(props => {
|
||||
const renderer = useStrictContext(MenuBuilderContext);
|
||||
const renderer = useStrictContext(RendererContext);
|
||||
if (renderer === 'AxoDropdownMenu') {
|
||||
return <AxoDropdownMenu.Group {...props} />;
|
||||
}
|
||||
@@ -98,10 +208,19 @@ export namespace AxoMenuBuilder {
|
||||
unreachable(renderer);
|
||||
});
|
||||
|
||||
Group.displayName = `${Namespace}.Group`;
|
||||
Group.displayName = 'AxoMenuBuilder.Group';
|
||||
|
||||
/**
|
||||
* <AxoMenuBuilder.Label>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Delegates to `AxoDropdownMenu.Label` or `AxoContextMenu.Label` based on
|
||||
* the active renderer.
|
||||
*/
|
||||
export const Label: FC<AxoBaseMenu.MenuLabelProps> = memo(props => {
|
||||
const renderer = useStrictContext(MenuBuilderContext);
|
||||
const renderer = useStrictContext(RendererContext);
|
||||
if (renderer === 'AxoDropdownMenu') {
|
||||
return <AxoDropdownMenu.Label {...props} />;
|
||||
}
|
||||
@@ -111,10 +230,19 @@ export namespace AxoMenuBuilder {
|
||||
unreachable(renderer);
|
||||
});
|
||||
|
||||
Label.displayName = `${Namespace}.Label`;
|
||||
Label.displayName = 'AxoMenuBuilder.Label';
|
||||
|
||||
/**
|
||||
* <AxoMenuBuilder.Separator>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Delegates to `AxoDropdownMenu.Separator` or `AxoContextMenu.Separator`
|
||||
* based on the active renderer.
|
||||
*/
|
||||
export const Separator: FC = memo(props => {
|
||||
const renderer = useStrictContext(MenuBuilderContext);
|
||||
const renderer = useStrictContext(RendererContext);
|
||||
if (renderer === 'AxoDropdownMenu') {
|
||||
return <AxoDropdownMenu.Separator {...props} />;
|
||||
}
|
||||
@@ -124,11 +252,20 @@ export namespace AxoMenuBuilder {
|
||||
unreachable(renderer);
|
||||
});
|
||||
|
||||
Separator.displayName = `${Namespace}.Separator`;
|
||||
Separator.displayName = 'AxoMenuBuilder.Separator';
|
||||
|
||||
/**
|
||||
* <AxoMenuBuilder.CheckboxItem>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Delegates to `AxoDropdownMenu.CheckboxItem` or
|
||||
* `AxoContextMenu.CheckboxItem` based on the active renderer.
|
||||
*/
|
||||
export const CheckboxItem: FC<AxoBaseMenu.MenuCheckboxItemProps> = memo(
|
||||
props => {
|
||||
const renderer = useStrictContext(MenuBuilderContext);
|
||||
const renderer = useStrictContext(RendererContext);
|
||||
if (renderer === 'AxoDropdownMenu') {
|
||||
return <AxoDropdownMenu.CheckboxItem {...props} />;
|
||||
}
|
||||
@@ -139,10 +276,19 @@ export namespace AxoMenuBuilder {
|
||||
}
|
||||
);
|
||||
|
||||
CheckboxItem.displayName = `${Namespace}.CheckboxItem`;
|
||||
CheckboxItem.displayName = 'AxoMenuBuilder.CheckboxItem';
|
||||
|
||||
/**
|
||||
* <AxoMenuBuilder.RadioGroup>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Delegates to `AxoDropdownMenu.RadioGroup` or `AxoContextMenu.RadioGroup`
|
||||
* based on the active renderer.
|
||||
*/
|
||||
export const RadioGroup: FC<AxoBaseMenu.MenuRadioGroupProps> = memo(props => {
|
||||
const renderer = useStrictContext(MenuBuilderContext);
|
||||
const renderer = useStrictContext(RendererContext);
|
||||
if (renderer === 'AxoDropdownMenu') {
|
||||
return <AxoDropdownMenu.RadioGroup {...props} />;
|
||||
}
|
||||
@@ -152,10 +298,19 @@ export namespace AxoMenuBuilder {
|
||||
unreachable(renderer);
|
||||
});
|
||||
|
||||
RadioGroup.displayName = `${Namespace}.RadioGroup`;
|
||||
RadioGroup.displayName = 'AxoMenuBuilder.RadioGroup';
|
||||
|
||||
/**
|
||||
* <AxoMenuBuilder.RadioItem>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Delegates to `AxoDropdownMenu.RadioItem` or `AxoContextMenu.RadioItem`
|
||||
* based on the active renderer.
|
||||
*/
|
||||
export const RadioItem: FC<AxoBaseMenu.MenuRadioItemProps> = memo(props => {
|
||||
const renderer = useStrictContext(MenuBuilderContext);
|
||||
const renderer = useStrictContext(RendererContext);
|
||||
if (renderer === 'AxoDropdownMenu') {
|
||||
return <AxoDropdownMenu.RadioItem {...props} />;
|
||||
}
|
||||
@@ -165,10 +320,19 @@ export namespace AxoMenuBuilder {
|
||||
unreachable(renderer);
|
||||
});
|
||||
|
||||
RadioItem.displayName = `${Namespace}.RadioItem`;
|
||||
RadioItem.displayName = 'AxoMenuBuilder.RadioItem';
|
||||
|
||||
/**
|
||||
* <AxoMenuBuilder.Sub>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Delegates to `AxoDropdownMenu.Sub` or `AxoContextMenu.Sub` based on the
|
||||
* active renderer.
|
||||
*/
|
||||
export const Sub: FC<AxoBaseMenu.MenuSubProps> = memo(props => {
|
||||
const renderer = useStrictContext(MenuBuilderContext);
|
||||
const renderer = useStrictContext(RendererContext);
|
||||
if (renderer === 'AxoDropdownMenu') {
|
||||
return <AxoDropdownMenu.Sub {...props} />;
|
||||
}
|
||||
@@ -178,10 +342,19 @@ export namespace AxoMenuBuilder {
|
||||
unreachable(renderer);
|
||||
});
|
||||
|
||||
Sub.displayName = `${Namespace}.Sub`;
|
||||
Sub.displayName = 'AxoMenuBuilder.Sub';
|
||||
|
||||
/**
|
||||
* <AxoMenuBuilder.SubTrigger>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Delegates to `AxoDropdownMenu.SubTrigger` or `AxoContextMenu.SubTrigger`
|
||||
* based on the active renderer.
|
||||
*/
|
||||
export const SubTrigger: FC<AxoBaseMenu.MenuSubTriggerProps> = memo(props => {
|
||||
const renderer = useStrictContext(MenuBuilderContext);
|
||||
const renderer = useStrictContext(RendererContext);
|
||||
if (renderer === 'AxoDropdownMenu') {
|
||||
return <AxoDropdownMenu.SubTrigger {...props} />;
|
||||
}
|
||||
@@ -191,10 +364,19 @@ export namespace AxoMenuBuilder {
|
||||
unreachable(renderer);
|
||||
});
|
||||
|
||||
SubTrigger.displayName = `${Namespace}.SubTrigger`;
|
||||
SubTrigger.displayName = 'AxoMenuBuilder.SubTrigger';
|
||||
|
||||
/**
|
||||
* <AxoMenuBuilder.SubContent>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Delegates to `AxoDropdownMenu.SubContent` or `AxoContextMenu.SubContent`
|
||||
* based on the active renderer.
|
||||
*/
|
||||
export const SubContent: FC<AxoBaseMenu.MenuSubContentProps> = memo(props => {
|
||||
const renderer = useStrictContext(MenuBuilderContext);
|
||||
const renderer = useStrictContext(RendererContext);
|
||||
if (renderer === 'AxoDropdownMenu') {
|
||||
return <AxoDropdownMenu.SubContent {...props} />;
|
||||
}
|
||||
@@ -204,5 +386,5 @@ export namespace AxoMenuBuilder {
|
||||
unreachable(renderer);
|
||||
});
|
||||
|
||||
SubContent.displayName = `${Namespace}.SubContent`;
|
||||
SubContent.displayName = 'AxoMenuBuilder.SubContent';
|
||||
}
|
||||
|
||||
@@ -4,14 +4,21 @@ import type { FC, ReactNode } from 'react';
|
||||
import { memo, useInsertionEffect } from 'react';
|
||||
import { Direction, Tooltip } from 'radix-ui';
|
||||
import { createScrollbarGutterCssProperties } from './_internal/scrollbars.dom.tsx';
|
||||
import { AxoIntl } from './_internal/AxoIntl.dom.tsx';
|
||||
|
||||
type AxoProviderProps = Readonly<{
|
||||
export type AxoProviderProps = Readonly<{
|
||||
/** Text direction for the application. */
|
||||
dir: 'ltr' | 'rtl';
|
||||
/** Localized strings used by Axo components. */
|
||||
messages: AxoIntl.Messages;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
let runOnceGlobally = false;
|
||||
|
||||
/**
|
||||
* Root provider for all Axo components.
|
||||
*/
|
||||
export const AxoProvider: FC<AxoProviderProps> = memo(props => {
|
||||
useInsertionEffect(() => {
|
||||
if (runOnceGlobally) {
|
||||
@@ -26,10 +33,13 @@ export const AxoProvider: FC<AxoProviderProps> = memo(props => {
|
||||
runOnceGlobally = false;
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Direction.Provider dir={props.dir}>
|
||||
<Tooltip.Provider>{props.children}</Tooltip.Provider>
|
||||
</Direction.Provider>
|
||||
<AxoIntl.Provider messages={props.messages}>
|
||||
<Direction.Provider dir={props.dir}>
|
||||
<Tooltip.Provider>{props.children}</Tooltip.Provider>
|
||||
</Direction.Provider>
|
||||
</AxoIntl.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -10,32 +10,72 @@ import {
|
||||
useStrictContext,
|
||||
} from './_internal/StrictContext.dom.tsx';
|
||||
|
||||
const Namespace = 'AxoRadioGroup';
|
||||
|
||||
/**
|
||||
* A set of checkable buttons—known as radio buttons—where no more than one of
|
||||
* the buttons can be checked at a time.
|
||||
*
|
||||
* @example Anatomy
|
||||
* ```tsx
|
||||
* <AxoRadioGroup.Root>
|
||||
* <AxoRadioGroup.Item>
|
||||
* <AxoRadioGroup.Indicator/>
|
||||
* <AxoRadioGroup.Label>...</AxoRadioGroup.Label>
|
||||
* <AxoRadioGroup.Root value={value} onValueChange={setValue}>
|
||||
* <AxoRadioGroup.Item value="option-a">
|
||||
* <AxoRadioGroup.Indicator />
|
||||
* <AxoRadioGroup.Label>Option A</AxoRadioGroup.Label>
|
||||
* </AxoRadioGroup.Item>
|
||||
* </AxoAlertDialog.Root>
|
||||
* </AxoRadioGroup.Root>
|
||||
* ```
|
||||
*
|
||||
* @see {@link https://www.radix-ui.com/primitives/docs/components/radio-group | Radio Group - Radix Docs}
|
||||
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/radio/ | Radio Pattern - ARIA Authoring Practices Guide}
|
||||
* @see {@link https://w3c.github.io/aria/#radiogroup | `radiogroup` role - WAI-ARIA 1.3}
|
||||
* @see {@link https://w3c.github.io/aria/#radio | `radio` role - WAI-ARIA 1.3}
|
||||
*/
|
||||
export namespace AxoRadioGroup {
|
||||
/**
|
||||
* Component: <AxoRadioGroup.Root>
|
||||
* -------------------------------
|
||||
* <AxoRadioGroup.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type RootProps = Readonly<{
|
||||
/**
|
||||
* The controlled value of the radio item to check.
|
||||
* Should be used in conjunction with `onValueChange`.
|
||||
*/
|
||||
value: string | null;
|
||||
/**
|
||||
* Event handler called when the value changes.
|
||||
*/
|
||||
onValueChange: (value: string) => void;
|
||||
/**
|
||||
* When `true`, prevents the user from interacting with radio items.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Should be `Item` elements.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Contains all the parts of a radio group.
|
||||
*
|
||||
* @example Notification preference
|
||||
* ```tsx
|
||||
* <AxoRadioGroup.Root value={notify} onValueChange={setNotify}>
|
||||
* <AxoRadioGroup.Item value="all">
|
||||
* <AxoRadioGroup.Indicator />
|
||||
* <AxoRadioGroup.Label>All messages</AxoRadioGroup.Label>
|
||||
* </AxoRadioGroup.Item>
|
||||
* <AxoRadioGroup.Item value="mentions">
|
||||
* <AxoRadioGroup.Indicator />
|
||||
* <AxoRadioGroup.Label>Mentions only</AxoRadioGroup.Label>
|
||||
* </AxoRadioGroup.Item>
|
||||
* <AxoRadioGroup.Item value="off">
|
||||
* <AxoRadioGroup.Indicator />
|
||||
* <AxoRadioGroup.Label>Off</AxoRadioGroup.Label>
|
||||
* </AxoRadioGroup.Item>
|
||||
* </AxoRadioGroup.Root>
|
||||
* ```
|
||||
*/
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
return (
|
||||
<RadioGroup.Root
|
||||
@@ -49,27 +89,42 @@ export namespace AxoRadioGroup {
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = `${Namespace}.Root`;
|
||||
Root.displayName = 'AxoRadioGroup.Root';
|
||||
|
||||
/**
|
||||
* Component: <AxoRadioGroup.Item>
|
||||
* -------------------------------
|
||||
* <AxoRadioGroup.Item>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/** @internal */
|
||||
type ItemContextType = Readonly<{
|
||||
id: string;
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
}>;
|
||||
|
||||
const ItemContext = createStrictContext<ItemContextType>(`${Namespace}.Item`);
|
||||
/** @internal */
|
||||
const ItemContext =
|
||||
createStrictContext<ItemContextType>('AxoRadioGroup.Item');
|
||||
|
||||
export type ItemProps = Readonly<{
|
||||
/**
|
||||
* The value given as data when submitted with a name.
|
||||
*/
|
||||
value: string;
|
||||
/**
|
||||
* When true, prevents the user from interacting with the radio item.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Should be an `Indicator` and a `Label`.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A single radio option.
|
||||
*/
|
||||
export const Item: FC<ItemProps> = memo(props => {
|
||||
const { value, disabled = false } = props;
|
||||
const id = useId();
|
||||
@@ -87,13 +142,16 @@ export namespace AxoRadioGroup {
|
||||
);
|
||||
});
|
||||
|
||||
Item.displayName = `${Namespace}.Item`;
|
||||
Item.displayName = 'AxoRadioGroup.Item';
|
||||
|
||||
/**
|
||||
* Component: <AxoRadioGroup.Indicator>
|
||||
* ------------------------------------
|
||||
* <AxoRadioGroup.Indicator>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Renders when the radio item is in a checked state.
|
||||
*/
|
||||
export const Indicator: FC = memo(() => {
|
||||
const context = useStrictContext(ItemContext);
|
||||
return (
|
||||
@@ -117,7 +175,7 @@ export namespace AxoRadioGroup {
|
||||
<RadioGroup.Indicator asChild>
|
||||
<span
|
||||
className={tw(
|
||||
'size-[9px] rounded-full',
|
||||
'size-2.25 rounded-full',
|
||||
'data-[state=checked]:bg-label-primary-on-color',
|
||||
'data-[state=checked]:data-disabled:bg-label-disabled-on-color',
|
||||
'forced-colors:data-[state=checked]:bg-[SelectedItemText]'
|
||||
@@ -128,17 +186,23 @@ export namespace AxoRadioGroup {
|
||||
);
|
||||
});
|
||||
|
||||
Indicator.displayName = `${Namespace}.Indicator`;
|
||||
Indicator.displayName = 'AxoRadioGroup.Indicator';
|
||||
|
||||
/**
|
||||
* Component: <AxoRadioGroup.Indicator>
|
||||
* ------------------------------------
|
||||
* <AxoRadioGroup.Label>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type LabelProps = Readonly<{
|
||||
/**
|
||||
* The visible text for this option.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Text label for a radio item.
|
||||
*/
|
||||
export const Label: FC<LabelProps> = memo(props => {
|
||||
return (
|
||||
<span className={tw('truncate type-body-large text-label-primary')}>
|
||||
@@ -147,5 +211,5 @@ export namespace AxoRadioGroup {
|
||||
);
|
||||
});
|
||||
|
||||
Label.displayName = `${Namespace}.Label`;
|
||||
Label.displayName = 'AxoRadioGroup.Label';
|
||||
}
|
||||
|
||||
+271
-145
@@ -2,38 +2,21 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import type { CSSProperties, FC, ReactNode } from 'react';
|
||||
import type { TailwindStyles } from './tw.dom.tsx';
|
||||
import { tw } from './tw.dom.tsx';
|
||||
import {
|
||||
createStrictContext,
|
||||
useStrictContext,
|
||||
} from './_internal/StrictContext.dom.tsx';
|
||||
import { AxoTooltip } from './AxoTooltip.dom.tsx';
|
||||
|
||||
const Namespace = 'AxoScrollArea';
|
||||
|
||||
const AXO_SCROLL_AREA_TIMELINE_VERTICAL = '--axo-scroll-area-timeline-vertical';
|
||||
const AXO_SCROLL_AREA_TIMELINE_HORIZONTAL =
|
||||
'--axo-scroll-area-timeline-horizontal';
|
||||
|
||||
type AxoScrollAreaOrientation = 'vertical' | 'horizontal' | 'both';
|
||||
|
||||
const AxoScrollAreaOrientationContext =
|
||||
createStrictContext<AxoScrollAreaOrientation>(`${Namespace}.Root`);
|
||||
|
||||
function useAxoScrollAreaOrientation(): AxoScrollArea.Orientation {
|
||||
return useStrictContext(AxoScrollAreaOrientationContext);
|
||||
}
|
||||
import { variants } from './_internal/variants.dom.tsx';
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
* A scrollable container with configurable scrollbar styling, optional scroll
|
||||
* hints (edge indicators), and an optional gradient mask.
|
||||
*
|
||||
* @example Anatomy
|
||||
* ```tsx
|
||||
* <AxoScrollArea.Root>
|
||||
* <AxoScrollArea.Root scrollbarWidth="thin">
|
||||
* <AxoScrollArea.Hint edge="top"/>
|
||||
* <AxoScrollArea.Hint edge="bottom"/>
|
||||
* <AxoScrollArea.Mask>
|
||||
@@ -48,53 +31,123 @@ function useAxoScrollAreaOrientation(): AxoScrollArea.Orientation {
|
||||
*/
|
||||
export namespace AxoScrollArea {
|
||||
/**
|
||||
* Context: ScrollAreaOrientation
|
||||
* <AxoScrollArea.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type Orientation = AxoScrollAreaOrientation;
|
||||
const AXO_SCROLL_AREA_TIMELINE_VERTICAL =
|
||||
'--axo-scroll-area-timeline-vertical';
|
||||
const AXO_SCROLL_AREA_TIMELINE_HORIZONTAL =
|
||||
'--axo-scroll-area-timeline-horizontal';
|
||||
|
||||
/**
|
||||
* Context: ScrollAreaConfig
|
||||
* Which directions the area scrolls:
|
||||
* - `vertical`: Scrolls up/down (default).
|
||||
* - `horizontal`: Scrolls left/right.
|
||||
* - `both`: Scrolls in both axes.
|
||||
*/
|
||||
export type Orientation = 'vertical' | 'horizontal' | 'both';
|
||||
|
||||
/**
|
||||
* Width of the native scrollbar track:
|
||||
* - `wide`: Full-width system scrollbar.
|
||||
* - `thin`: Narrow overlay-style scrollbar.
|
||||
* - `none`: No scrollbar rendered (content still scrollable).
|
||||
*/
|
||||
export type ScrollbarWidth = 'wide' | 'thin' | 'none';
|
||||
|
||||
/**
|
||||
* Space reserved for the scrollbar to prevent layout shifts.
|
||||
* - `unstable`: No reserved space, layout shifts when scrollbar appears (default).
|
||||
* - `stable-one-edge`: Reserved space on one side only.
|
||||
* - `stable-both-edges`: Reserved space on both sides, keeps content centered.
|
||||
*/
|
||||
|
||||
export type ScrollbarGutter =
|
||||
| 'unstable'
|
||||
| 'stable-one-edge'
|
||||
| 'stable-both-edges';
|
||||
|
||||
/**
|
||||
* Whether programmatic scrolling (e.g. `scrollIntoView`) is animated by default.
|
||||
* - `auto`: Instant scroll (default).
|
||||
* - `smooth`: Animated scroll.
|
||||
*/
|
||||
export type ScrollBehavior = 'auto' | 'smooth';
|
||||
|
||||
/**
|
||||
* When the scrollbar thumb is visible.
|
||||
* - `auto`: Always visible when content overflows (default).
|
||||
* - `as-needed`: Fades out when the area is not hovered or focused.
|
||||
*/
|
||||
export type ScrollbarVisibility = 'auto' | 'as-needed';
|
||||
|
||||
type ScrollAreaConfig = Readonly<{
|
||||
/** @internal */
|
||||
type RootContextText = Readonly<{
|
||||
orientation: Orientation;
|
||||
scrollbarWidth: ScrollbarWidth;
|
||||
scrollbarGutter: ScrollbarGutter;
|
||||
scrollbarVisibility: ScrollbarVisibility;
|
||||
scrollBehavior: ScrollBehavior;
|
||||
}>;
|
||||
|
||||
const ScrollAreaConfigContext = createStrictContext<ScrollAreaConfig>(
|
||||
`${Namespace}.Root`
|
||||
);
|
||||
|
||||
/**
|
||||
* Component: <AxoScrollArea.Root>
|
||||
* -------------------------------
|
||||
*/
|
||||
/** @internal */
|
||||
const RootContext =
|
||||
createStrictContext<RootContextText>('AxoScrollArea.Root');
|
||||
|
||||
export type RootProps = Readonly<{
|
||||
/**
|
||||
* Which axis(es) the area scrolls. Defaults to `vertical`.
|
||||
*/
|
||||
orientation?: Orientation;
|
||||
/**
|
||||
* Constrains the width of the scroll area.
|
||||
*/
|
||||
maxWidth?: number;
|
||||
/**
|
||||
* Constrains the height of the scroll area.
|
||||
* Use when the container is trying to fit the height of its children.
|
||||
*/
|
||||
maxHeight?: number;
|
||||
/**
|
||||
* Width of the native scrollbar track.
|
||||
* Use when the container is trying to fit the width of its children.
|
||||
*/
|
||||
scrollbarWidth: ScrollbarWidth;
|
||||
/**
|
||||
* Space reserved for the scrollbar. Defaults to `stable-both-edges`.
|
||||
*/
|
||||
scrollbarGutter?: ScrollbarGutter;
|
||||
/**
|
||||
* When the scrollbar thumb is visible. Defaults to `auto`.
|
||||
*/
|
||||
scrollbarVisibility?: ScrollbarVisibility;
|
||||
/**
|
||||
* Whether programmatic scrolling is animated by default.
|
||||
* Defaults to `auto`.
|
||||
*/
|
||||
scrollBehavior?: ScrollBehavior;
|
||||
/**
|
||||
* Should be `Hint`, `Mask`, and/or `Viewport` elements.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Container that configures the scroll area. Provides scroll settings to
|
||||
* child `Viewport`, `Hint`, and `Mask` elements.
|
||||
*
|
||||
* @example Vertical scroll with thin scrollbar
|
||||
* ```tsx
|
||||
* <AxoScrollArea.Root scrollbarWidth="thin" scrollbarVisibility="as-needed" maxHeight={400}>
|
||||
* <AxoScrollArea.Hint edge="top" />
|
||||
* <AxoScrollArea.Hint edge="bottom" />
|
||||
* <AxoScrollArea.Viewport>
|
||||
* <AxoScrollArea.Content>{items}</AxoScrollArea.Content>
|
||||
* </AxoScrollArea.Viewport>
|
||||
* </AxoScrollArea.Root>
|
||||
* ```
|
||||
*/
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
const {
|
||||
orientation = 'vertical',
|
||||
@@ -106,14 +159,21 @@ export namespace AxoScrollArea {
|
||||
scrollBehavior = 'auto',
|
||||
} = props;
|
||||
|
||||
const config = useMemo((): ScrollAreaConfig => {
|
||||
const context = useMemo((): RootContextText => {
|
||||
return {
|
||||
orientation,
|
||||
scrollbarWidth,
|
||||
scrollbarGutter,
|
||||
scrollbarVisibility,
|
||||
scrollBehavior,
|
||||
};
|
||||
}, [scrollbarWidth, scrollbarGutter, scrollbarVisibility, scrollBehavior]);
|
||||
}, [
|
||||
orientation,
|
||||
scrollbarWidth,
|
||||
scrollbarGutter,
|
||||
scrollbarVisibility,
|
||||
scrollBehavior,
|
||||
]);
|
||||
|
||||
const style = useMemo((): CSSProperties => {
|
||||
return {
|
||||
@@ -126,31 +186,29 @@ export namespace AxoScrollArea {
|
||||
}, [maxWidth, maxHeight]);
|
||||
|
||||
return (
|
||||
<AxoScrollAreaOrientationContext.Provider value={orientation}>
|
||||
<ScrollAreaConfigContext.Provider value={config}>
|
||||
<div
|
||||
className={tw(
|
||||
'relative z-0',
|
||||
'flex size-full flex-col overflow-hidden',
|
||||
'rounded-[2px]',
|
||||
// Move the outline from the viewport to the parent
|
||||
// so it doesn't get cut off by <Mask>
|
||||
'keyboard-mode:has-[[data-axo-scroll-area-viewport]:focus]:outline-focus-ring'
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</ScrollAreaConfigContext.Provider>
|
||||
</AxoScrollAreaOrientationContext.Provider>
|
||||
<RootContext.Provider value={context}>
|
||||
<div
|
||||
className={tw(
|
||||
'relative z-0',
|
||||
'flex size-full flex-col overflow-hidden',
|
||||
'rounded-[2px]',
|
||||
// Move the outline from the viewport to the parent
|
||||
// so it doesn't get cut off by <Mask>
|
||||
'keyboard-mode:has-[[data-axo-scroll-area-viewport]:focus]:outline-focus-ring'
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</RootContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = `${Namespace}.Root`;
|
||||
Root.displayName = 'AxoScrollArea.Root';
|
||||
|
||||
/**
|
||||
* Component: <AxoScrollArea.Viewport>
|
||||
* -----------------------------------
|
||||
* <AxoScrollArea.Viewport>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const baseViewportStyles = tw(
|
||||
@@ -161,62 +219,79 @@ export namespace AxoScrollArea {
|
||||
'outline-none'
|
||||
);
|
||||
|
||||
const ViewportScrollbarWidths: Record<ScrollbarWidth, TailwindStyles> = {
|
||||
wide: tw('scrollbar-width-auto'),
|
||||
thin: tw('scrollbar-width-thin'),
|
||||
none: tw('scrollbar-width-none'),
|
||||
};
|
||||
const ViewportScrollbarWidths = variants<ScrollbarWidth>(
|
||||
'AxoScrollArea.ScrollbarWidth',
|
||||
{
|
||||
wide: tw('scrollbar-width-auto'),
|
||||
thin: tw('scrollbar-width-thin'),
|
||||
none: tw('scrollbar-width-none'),
|
||||
}
|
||||
);
|
||||
|
||||
const ViewportScrollbarGutters: Record<ScrollbarGutter, TailwindStyles> = {
|
||||
unstable: tw('scrollbar-gutter-auto'),
|
||||
'stable-one-edge': tw('scrollbar-gutter-stable'),
|
||||
'stable-both-edges': tw('scrollbar-gutter-stable'),
|
||||
};
|
||||
const ViewportScrollbarGutters = variants<ScrollbarGutter>(
|
||||
'AxoScrollArea.ScrollbarGutter',
|
||||
{
|
||||
unstable: tw('scrollbar-gutter-auto'),
|
||||
'stable-one-edge': tw('scrollbar-gutter-stable'),
|
||||
'stable-both-edges': tw('scrollbar-gutter-stable'),
|
||||
}
|
||||
);
|
||||
|
||||
const ViewportScrollbarVisibilities: Record<
|
||||
ScrollbarVisibility,
|
||||
TailwindStyles
|
||||
> = {
|
||||
auto: tw(),
|
||||
'as-needed': tw(
|
||||
'transition-[scrollbar-color] duration-150 not-hover:not-focus-within:scrollbar-thumb-transparent'
|
||||
),
|
||||
};
|
||||
const ViewportScrollbarVisibilities = variants<ScrollbarVisibility>(
|
||||
'AxoScrollArea.ScrollbarVisibility',
|
||||
{
|
||||
auto: tw(),
|
||||
'as-needed': tw(
|
||||
'transition-[scrollbar-color] duration-150 not-hover:not-focus-within:scrollbar-thumb-transparent'
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
const ViewportScrollBehaviors: Record<ScrollBehavior, TailwindStyles> = {
|
||||
auto: tw('scroll-auto'),
|
||||
smooth: tw('scroll-smooth'),
|
||||
};
|
||||
const ViewportScrollBehaviors = variants<ScrollBehavior>(
|
||||
'AxoScrollArea.ScrollBehavior',
|
||||
{
|
||||
auto: tw('scroll-auto'),
|
||||
smooth: tw('scroll-smooth'),
|
||||
}
|
||||
);
|
||||
|
||||
type GutterCss = { horizontal: string; vertical: string };
|
||||
const ScrollbarWidthGutterVertical = variants<ScrollbarWidth, string>(
|
||||
'AxoScrollArea.ScrollbarWidth',
|
||||
{
|
||||
wide: 'var(--axo-scrollbar-gutter-auto-vertical)',
|
||||
thin: 'var(--axo-scrollbar-gutter-thin-vertical)',
|
||||
none: '0px',
|
||||
}
|
||||
);
|
||||
|
||||
const ScrollbarWidthToGutterCss: Record<ScrollbarWidth, GutterCss> = {
|
||||
wide: {
|
||||
vertical: 'var(--axo-scrollbar-gutter-auto-vertical)',
|
||||
horizontal: 'var(--axo-scrollbar-gutter-auto-horizontal)',
|
||||
},
|
||||
thin: {
|
||||
vertical: 'var(--axo-scrollbar-gutter-thin-vertical)',
|
||||
horizontal: 'var(--axo-scrollbar-gutter-thin-horizontal)',
|
||||
},
|
||||
none: {
|
||||
vertical: '0px',
|
||||
horizontal: '0px',
|
||||
},
|
||||
};
|
||||
const ScrollbarWidthGutterHorizontal = variants<ScrollbarWidth, string>(
|
||||
'AxoScrollArea.ScrollbarWidth',
|
||||
{
|
||||
wide: 'var(--axo-scrollbar-gutter-auto-horizontal)',
|
||||
thin: 'var(--axo-scrollbar-gutter-thin-horizontal)',
|
||||
none: '0px',
|
||||
}
|
||||
);
|
||||
|
||||
export type ViewportProps = Readonly<{
|
||||
/**
|
||||
* Should be a `Content` element.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The scrollable element.
|
||||
* Must be placed inside `Root`, and should wrap a `Content`.
|
||||
*/
|
||||
export const Viewport: FC<ViewportProps> = memo(props => {
|
||||
const orientation = useAxoScrollAreaOrientation();
|
||||
const {
|
||||
orientation,
|
||||
scrollbarWidth,
|
||||
scrollbarGutter,
|
||||
scrollbarVisibility,
|
||||
scrollBehavior,
|
||||
} = useStrictContext(ScrollAreaConfigContext);
|
||||
} = useStrictContext(RootContext);
|
||||
const [boundary, setBoundary] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const style = useMemo((): CSSProperties => {
|
||||
@@ -230,11 +305,10 @@ export namespace AxoScrollArea {
|
||||
let paddingInlineStart: string | undefined;
|
||||
if (scrollbarGutter === 'stable-both-edges') {
|
||||
if (hasVerticalScrollbar) {
|
||||
paddingInlineStart =
|
||||
ScrollbarWidthToGutterCss[scrollbarWidth].vertical;
|
||||
paddingInlineStart = ScrollbarWidthGutterVertical.get(scrollbarWidth);
|
||||
}
|
||||
if (hasHorizontalScrollbar) {
|
||||
paddingTop = ScrollbarWidthToGutterCss[scrollbarWidth].horizontal;
|
||||
paddingTop = ScrollbarWidthGutterHorizontal.get(scrollbarWidth);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,10 +343,10 @@ export namespace AxoScrollArea {
|
||||
data-axo-scroll-area-viewport
|
||||
className={tw(
|
||||
baseViewportStyles,
|
||||
ViewportScrollbarWidths[scrollbarWidth],
|
||||
ViewportScrollbarGutters[scrollbarGutter],
|
||||
ViewportScrollbarVisibilities[scrollbarVisibility],
|
||||
ViewportScrollBehaviors[scrollBehavior]
|
||||
ViewportScrollbarWidths.get(scrollbarWidth),
|
||||
ViewportScrollbarGutters.get(scrollbarGutter),
|
||||
ViewportScrollbarVisibilities.get(scrollbarVisibility),
|
||||
ViewportScrollBehaviors.get(scrollBehavior)
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
@@ -282,14 +356,17 @@ export namespace AxoScrollArea {
|
||||
);
|
||||
});
|
||||
|
||||
Viewport.displayName = `${Namespace}.Viewport`;
|
||||
Viewport.displayName = 'AxoScrollArea.Viewport';
|
||||
|
||||
/**
|
||||
* Component: <AxoScrollArea.Content>
|
||||
* ----------------------------------
|
||||
* <AxoScrollArea.Content>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ContentProps = Readonly<{
|
||||
/**
|
||||
* The scrollable content.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
@@ -307,17 +384,27 @@ export namespace AxoScrollArea {
|
||||
'grow'
|
||||
);
|
||||
|
||||
/**
|
||||
* Wrapper for the content inside `Viewport`.
|
||||
*
|
||||
* Sizes itself to fit content while also filling available space.
|
||||
*/
|
||||
export const Content: FC<ContentProps> = memo(props => {
|
||||
return <div className={contentStyles}>{props.children}</div>;
|
||||
});
|
||||
|
||||
Content.displayName = `${Namespace}.Content`;
|
||||
Content.displayName = 'AxoScrollArea.Content';
|
||||
|
||||
/**
|
||||
* Component: <AxoScrollArea.Hint>
|
||||
* -------------------------------
|
||||
* <AxoScrollArea.Hint>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Which edge of the scroll area the hint appears on.
|
||||
* - `top` / `bottom`: For vertically scrollable areas.
|
||||
* - `inline-start` / `inline-end`: For horizontally scrollable areas.
|
||||
*/
|
||||
export type Edge = 'top' | 'bottom' | 'inline-start' | 'inline-end';
|
||||
|
||||
const edgeStyles = tw(
|
||||
@@ -335,47 +422,57 @@ export namespace AxoScrollArea {
|
||||
const edgeYStyles = tw('inset-x-0 h-0.5 forced-colors:h-px');
|
||||
const edgeXStyles = tw('inset-y-0 w-0.5 forced-colors:w-px');
|
||||
|
||||
const HintEdges: Record<Edge, TailwindStyles> = {
|
||||
const HintEdges = variants<Edge>('AxoScrollArea.Edge', {
|
||||
top: tw(
|
||||
edgeStyles,
|
||||
edgeYStyles,
|
||||
edgeStartStyles,
|
||||
'top-0',
|
||||
'bg-linear-to-b'
|
||||
tw(edgeStyles, edgeYStyles, edgeStartStyles),
|
||||
'top-0 bg-linear-to-b'
|
||||
),
|
||||
bottom: tw(
|
||||
edgeStyles,
|
||||
edgeYStyles,
|
||||
edgeEndStyles,
|
||||
'bottom-0',
|
||||
'bg-linear-to-t'
|
||||
tw(edgeStyles, edgeYStyles, edgeEndStyles),
|
||||
'bottom-0 bg-linear-to-t'
|
||||
),
|
||||
'inline-start': tw(
|
||||
edgeStyles,
|
||||
edgeXStyles,
|
||||
edgeStartStyles,
|
||||
'inset-s-0',
|
||||
'bg-linear-to-r rtl:bg-linear-to-l'
|
||||
tw(edgeStyles, edgeXStyles, edgeStartStyles),
|
||||
'inset-s-0 bg-linear-to-r rtl:bg-linear-to-l'
|
||||
),
|
||||
'inline-end': tw(
|
||||
edgeStyles,
|
||||
edgeXStyles,
|
||||
edgeEndStyles,
|
||||
'inset-e-0',
|
||||
'bg-linear-to-l rtl:bg-linear-to-r'
|
||||
tw(edgeStyles, edgeXStyles, edgeEndStyles),
|
||||
'inset-e-0 bg-linear-to-l rtl:bg-linear-to-r'
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
export type HintProps = Readonly<{
|
||||
/**
|
||||
* Scroll offset (px) at which the hint becomes fully visible.
|
||||
* Defaults to `1` (appears as soon as the user has scrolled 1px).
|
||||
*/
|
||||
animationStartOffset?: number;
|
||||
/**
|
||||
* Scroll offset (px) from the end at which the hint starts to fade out.
|
||||
* Defaults to `20`.
|
||||
*/
|
||||
animationEndOffset?: number;
|
||||
/**
|
||||
* Which edge of the scroll area to show the hint on.
|
||||
*/
|
||||
edge: Edge;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A thin gradient line that fades in at a scroll edge to signal there is
|
||||
* more content in that direction.
|
||||
*
|
||||
* Place inside `Root`, outside `Viewport`.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AxoScrollArea.Hint edge="top" />
|
||||
* <AxoScrollArea.Hint edge="bottom" />
|
||||
* ```
|
||||
*/
|
||||
export const Hint: FC<HintProps> = memo(props => {
|
||||
const { edge, animationStartOffset = 1, animationEndOffset = 20 } = props;
|
||||
const orientation = useAxoScrollAreaOrientation();
|
||||
const { scrollbarWidth } = useStrictContext(ScrollAreaConfigContext);
|
||||
const { orientation, scrollbarWidth } = useStrictContext(RootContext);
|
||||
|
||||
const style = useMemo((): CSSProperties => {
|
||||
const isVerticalEdge = edge === 'top' || edge === 'bottom';
|
||||
@@ -384,11 +481,11 @@ export namespace AxoScrollArea {
|
||||
return {
|
||||
insetInlineEnd:
|
||||
edge !== 'inline-start' && orientation === 'both'
|
||||
? ScrollbarWidthToGutterCss[scrollbarWidth].horizontal
|
||||
? ScrollbarWidthGutterHorizontal.get(scrollbarWidth)
|
||||
: undefined,
|
||||
bottom:
|
||||
edge !== 'top' && orientation === 'both'
|
||||
? ScrollbarWidthToGutterCss[scrollbarWidth].vertical
|
||||
? ScrollbarWidthGutterVertical.get(scrollbarWidth)
|
||||
: undefined,
|
||||
animationTimeline: isVerticalEdge
|
||||
? AXO_SCROLL_AREA_TIMELINE_VERTICAL
|
||||
@@ -408,31 +505,61 @@ export namespace AxoScrollArea {
|
||||
animationEndOffset,
|
||||
]);
|
||||
|
||||
return <div className={HintEdges[edge]} style={style} />;
|
||||
return <div className={HintEdges.get(edge)} style={style} />;
|
||||
});
|
||||
|
||||
Hint.displayName = `${Namespace}.Hint`;
|
||||
Hint.displayName = 'AxoScrollArea.Hint';
|
||||
|
||||
/**
|
||||
* Component: <AxoScrollArea.Mask>
|
||||
* -------------------------------
|
||||
* <AxoScrollArea.Mask>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type MaskProps = Readonly<{
|
||||
/**
|
||||
* Fully-transparent (clipped) zone at the start edge in px.
|
||||
* Defaults to `0`.
|
||||
*/
|
||||
maskStart?: number;
|
||||
/**
|
||||
* Gradient blend zone at each masked edge in px.
|
||||
* Defaults to `4`.
|
||||
*/
|
||||
maskPadding?: number;
|
||||
/**
|
||||
* Fade-out zone at the scrollable end edge in px.
|
||||
* Defaults to `40`.
|
||||
*/
|
||||
maskEnd?: number;
|
||||
|
||||
/**
|
||||
* Scroll offset at which the start-edge mask begins animating in.
|
||||
* Defaults to `maskStart`.
|
||||
*/
|
||||
animationStart?: number;
|
||||
/**
|
||||
* Blend zone used during animation.
|
||||
* Defaults to `maskPadding`.
|
||||
*/
|
||||
animationPadding?: number;
|
||||
/**
|
||||
* Scroll offset at which the end-edge mask is fully visible.
|
||||
* Defaults to `maskEnd * 3`.
|
||||
*/
|
||||
animationEnd?: number;
|
||||
|
||||
/**
|
||||
* Should be a `Viewport` element.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
// These styles are very complex so they are in a separate CSS file
|
||||
const AXO_MASK_CLASS_NAME = 'axo-scroll-area-mask';
|
||||
|
||||
/**
|
||||
* Applies a gradient fade mask at the scroll edges so content appears to
|
||||
* dissolve rather than abruptly clip. The mask animates in/out based on
|
||||
* scroll position. Wrap `Viewport` with this when a fade effect is needed.
|
||||
*/
|
||||
export const Mask: FC<MaskProps> = memo(props => {
|
||||
const {
|
||||
maskStart = 0,
|
||||
@@ -443,18 +570,17 @@ export namespace AxoScrollArea {
|
||||
animationEnd = maskEnd * 3,
|
||||
} = props;
|
||||
|
||||
const orientation = useAxoScrollAreaOrientation();
|
||||
const { scrollbarWidth } = useStrictContext(ScrollAreaConfigContext);
|
||||
const { orientation, scrollbarWidth } = useStrictContext(RootContext);
|
||||
|
||||
const style = useMemo(() => {
|
||||
const hasVerticalScrollbar = orientation !== 'horizontal';
|
||||
const hasHorizontalScrollbar = orientation !== 'vertical';
|
||||
|
||||
const verticalGutter = hasVerticalScrollbar
|
||||
? ScrollbarWidthToGutterCss[scrollbarWidth].vertical
|
||||
? ScrollbarWidthGutterVertical.get(scrollbarWidth)
|
||||
: '0px';
|
||||
const horizontalGutter = hasHorizontalScrollbar
|
||||
? ScrollbarWidthToGutterCss[scrollbarWidth].horizontal
|
||||
? ScrollbarWidthGutterHorizontal.get(scrollbarWidth)
|
||||
: '0px';
|
||||
|
||||
return {
|
||||
@@ -491,5 +617,5 @@ export namespace AxoScrollArea {
|
||||
);
|
||||
});
|
||||
|
||||
Mask.displayName = `${Namespace}.Mask`;
|
||||
Mask.displayName = 'AxoScrollArea.Mask';
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ function Template(props: {
|
||||
value={42}
|
||||
max={99}
|
||||
maxDisplay="99+"
|
||||
aria-label={null}
|
||||
label={null}
|
||||
/>
|
||||
)}
|
||||
</ExperimentalAxoSegmentedControl.Item>
|
||||
@@ -60,7 +60,7 @@ function Template(props: {
|
||||
value="mention"
|
||||
max={99}
|
||||
maxDisplay="99+"
|
||||
aria-label={null}
|
||||
label={null}
|
||||
/>
|
||||
)}
|
||||
</ExperimentalAxoSegmentedControl.Item>
|
||||
@@ -73,7 +73,7 @@ function Template(props: {
|
||||
value="unread"
|
||||
max={99}
|
||||
maxDisplay="99+"
|
||||
aria-label={null}
|
||||
label={null}
|
||||
/>
|
||||
)}
|
||||
</ExperimentalAxoSegmentedControl.Item>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { ButtonHTMLAttributes, FC, ForwardedRef, ReactNode } from 'react';
|
||||
import { forwardRef, memo, useCallback } from 'react';
|
||||
import type { FC, Ref, ReactNode } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { ToggleGroup } from 'radix-ui';
|
||||
import { ExperimentalAxoBaseSegmentedControl } from './_internal/AxoBaseSegmentedControl.dom.tsx';
|
||||
|
||||
const Namespace = 'AxoSegmentedControl';
|
||||
|
||||
/**
|
||||
* A row of mutually-exclusive buttons, used for tab-style navigation or
|
||||
* selecting a single option from a small set.
|
||||
*
|
||||
* @example Anatomy
|
||||
* ```tsx
|
||||
* <AxoSegmentedControl.Root>
|
||||
@@ -17,28 +18,87 @@ const Namespace = 'AxoSegmentedControl';
|
||||
* </AxoSegmentedControl.Item>
|
||||
* </AxoSegmentedControl.Root>
|
||||
* ```
|
||||
*
|
||||
* @see {@link https://www.radix-ui.com/primitives/docs/components/toggle-group | Toggle Group - Radix Docs}
|
||||
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/button/ | Button Pattern - ARIA Authoring Practices Guide}
|
||||
* @see {@link https://w3c.github.io/aria/#button | `button` role - WAI-ARIA 1.3}
|
||||
* @see {@link https://w3c.github.io/aria/#aria-pressed | `aria-pressed` state - WAI-ARIA 1.3}
|
||||
*/
|
||||
export namespace ExperimentalAxoSegmentedControl {
|
||||
/**
|
||||
* <AxoSegmentedControl.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Visual style of the control.
|
||||
*/
|
||||
export type Variant = ExperimentalAxoBaseSegmentedControl.Variant;
|
||||
|
||||
/**
|
||||
* Component: <AxoSegmentedControl.Root>
|
||||
* -------------------------------------
|
||||
* Width of the entire control:
|
||||
* - `fit`: Shrinks to fit the combined width of all items.
|
||||
* - `full`: Stretches to fill the container.
|
||||
*/
|
||||
|
||||
export type RootWidth = ExperimentalAxoBaseSegmentedControl.RootWidth;
|
||||
|
||||
/**
|
||||
* How each item is sized within the control:
|
||||
* - `fit`: Each item shrinks to fit its content.
|
||||
* - `equal`: All items share equal width.
|
||||
*/
|
||||
export type ItemWidth = ExperimentalAxoBaseSegmentedControl.ItemWidth;
|
||||
|
||||
export type RootProps = Readonly<{
|
||||
/**
|
||||
* Width of the entire control.
|
||||
*/
|
||||
width: RootWidth;
|
||||
/**
|
||||
* How each item is sized within the control.
|
||||
*/
|
||||
itemWidth: ItemWidth;
|
||||
/**
|
||||
* Visual style of the control.
|
||||
*/
|
||||
variant: Variant;
|
||||
/**
|
||||
* The controlled value of the pressed item.
|
||||
* Must be used in conjunction with `onValueChange`.
|
||||
*/
|
||||
value: string | null;
|
||||
/**
|
||||
* Event handler called when the pressed state of an item changes.
|
||||
*/
|
||||
onValueChange: (newValue: string | null) => void;
|
||||
/**
|
||||
* Should be `Item` elements.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
export const Root = memo((props: RootProps) => {
|
||||
/**
|
||||
* Container for the segmented control.
|
||||
*
|
||||
* @example Tab-style navigation
|
||||
* ```tsx
|
||||
* <AxoSegmentedControl.Root
|
||||
* variant="no-track"
|
||||
* width="full"
|
||||
* itemWidth="equal"
|
||||
* value={tab}
|
||||
* onValueChange={setTab}
|
||||
* >
|
||||
* <AxoSegmentedControl.Item value="media">
|
||||
* <AxoSegmentedControl.ItemText>Media</AxoSegmentedControl.ItemText>
|
||||
* </AxoSegmentedControl.Item>
|
||||
* <AxoSegmentedControl.Item value="audio">
|
||||
* <AxoSegmentedControl.ItemText>Audio</AxoSegmentedControl.ItemText>
|
||||
* </AxoSegmentedControl.Item>
|
||||
* </AxoSegmentedControl.Root>
|
||||
* ```
|
||||
*/
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
const { onValueChange } = props;
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
@@ -70,45 +130,65 @@ export namespace ExperimentalAxoSegmentedControl {
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = `${Namespace}.Root`;
|
||||
Root.displayName = 'AxoSegmentedControl.Root';
|
||||
|
||||
/**
|
||||
* Component: <AxoSegmentedControl.Item>
|
||||
* -------------------------------------
|
||||
* <AxoSegmentedControl.Item>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ItemProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
Readonly<{
|
||||
value: string;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
export const Item: FC<ItemProps> = memo(
|
||||
forwardRef((props: ItemProps, ref: ForwardedRef<HTMLButtonElement>) => {
|
||||
const { value, children, ...rest } = props;
|
||||
return (
|
||||
<ToggleGroup.Item {...rest} ref={ref} value={value} asChild>
|
||||
<ExperimentalAxoBaseSegmentedControl.Item value={value}>
|
||||
{children}
|
||||
</ExperimentalAxoBaseSegmentedControl.Item>
|
||||
</ToggleGroup.Item>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
Item.displayName = `${Namespace}.Item`;
|
||||
|
||||
/**
|
||||
* Component: <AxoSegmentedControl.ItemText>
|
||||
* -----------------------------------------
|
||||
*/
|
||||
|
||||
export type ItemTextProps = Readonly<{
|
||||
maxWidth?: ExperimentalAxoBaseSegmentedControl.ItemMaxWidth;
|
||||
export type ItemProps = Readonly<{
|
||||
/**
|
||||
* Ref to the underlying `<button>` element.
|
||||
*/
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
/**
|
||||
* A unique value for the item.
|
||||
*/
|
||||
value: string;
|
||||
/**
|
||||
* Should be an `ItemText`, optionally followed by an `ExperimentalItemBadge`.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
export const ItemText: FC<ItemTextProps> = memo((props: ItemTextProps) => {
|
||||
/**
|
||||
* An item in the group.
|
||||
*/
|
||||
export const Item: FC<ItemProps> = memo(props => {
|
||||
const { value, children, ...rest } = props;
|
||||
return (
|
||||
<ToggleGroup.Item asChild ref={props.ref} value={value} {...rest}>
|
||||
<ExperimentalAxoBaseSegmentedControl.Item value={value}>
|
||||
{children}
|
||||
</ExperimentalAxoBaseSegmentedControl.Item>
|
||||
</ToggleGroup.Item>
|
||||
);
|
||||
});
|
||||
|
||||
Item.displayName = 'AxoSegmentedControl.Item';
|
||||
|
||||
/**
|
||||
* <AxoSegmentedControl.ItemText>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ItemTextProps = Readonly<{
|
||||
/**
|
||||
* CSS `max-width` to apply to the text, e.g. `"12ch"` to prevent long
|
||||
* labels from stretching the control.
|
||||
*/
|
||||
maxWidth?: ExperimentalAxoBaseSegmentedControl.ItemMaxWidth;
|
||||
/**
|
||||
* The visible label for this item.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The text label inside an `Item`.
|
||||
*/
|
||||
export const ItemText: FC<ItemTextProps> = memo(props => {
|
||||
return (
|
||||
<ExperimentalAxoBaseSegmentedControl.ItemText maxWidth={props.maxWidth}>
|
||||
{props.children}
|
||||
@@ -116,15 +196,26 @@ export namespace ExperimentalAxoSegmentedControl {
|
||||
);
|
||||
});
|
||||
|
||||
ItemText.displayName = `${Namespace}.ItemText`;
|
||||
ItemText.displayName = 'AxoSegmentedControl.ItemText';
|
||||
|
||||
/**
|
||||
* Component: <AxoSegmentedControl.ItemBadge>
|
||||
* ------------------------------------------
|
||||
* <AxoSegmentedControl.ItemBadge>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ExperimentalItemBadgeProps =
|
||||
ExperimentalAxoBaseSegmentedControl.ExperimentalItemBadgeProps;
|
||||
|
||||
export const { ExperimentalItemBadge } = ExperimentalAxoBaseSegmentedControl;
|
||||
/**
|
||||
* A badge shown on an item, typically for unread counts.
|
||||
*/
|
||||
export const ExperimentalItemBadge: FC<ExperimentalItemBadgeProps> = memo(
|
||||
props => {
|
||||
return (
|
||||
<ExperimentalAxoBaseSegmentedControl.ExperimentalItemBadge {...props} />
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ExperimentalItemBadge.displayName = 'AxoSegmentedControl.ItemBadge';
|
||||
}
|
||||
|
||||
+223
-91
@@ -5,59 +5,107 @@ import type { FC, ReactNode } from 'react';
|
||||
import { Select } from 'radix-ui';
|
||||
import { AxoBaseMenu } from './_internal/AxoBaseMenu.dom.tsx';
|
||||
import { AxoSymbol } from './AxoSymbol.dom.tsx';
|
||||
import type { TailwindStyles } from './tw.dom.tsx';
|
||||
import { tw } from './tw.dom.tsx';
|
||||
import { ExperimentalAxoBadge } from './AxoBadge.dom.tsx';
|
||||
import { AxoTheme } from './AxoTheme.dom.tsx';
|
||||
|
||||
const Namespace = 'AxoSelect';
|
||||
import { variants } from './_internal/variants.dom.tsx';
|
||||
|
||||
/**
|
||||
* 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.ItemText/>
|
||||
* <AxoSelect.ItemBadge/>
|
||||
* <AxoSelect.Root value={value} onValueChange={setValue}>
|
||||
* <AxoSelect.Trigger placeholder="Choose…" />
|
||||
* <AxoSelect.Content>
|
||||
* <AxoSelect.Item value="a">
|
||||
* <AxoSelect.ItemText>Option A</AxoSelect.ItemText>
|
||||
* </AxoSelect.Item>
|
||||
* <AxoSelect.Separator />
|
||||
* <AxoSelect.Group>
|
||||
* <AxoSelect.Label>Group</AxoSelect.Label>
|
||||
* <AxoSelect.Item value="b">
|
||||
* <AxoSelect.ItemText>Option B</AxoSelect.ItemText>
|
||||
* </AxoSelect.Item>
|
||||
* <AxoSelect.Separator/>
|
||||
* <AxoSelect.Group>
|
||||
* <AxoSelect.Label/>
|
||||
* <AxoSelect.Item>
|
||||
* <AxoSelect.ItemText/>
|
||||
* </AxoSelect.Item>
|
||||
* </AxoSelect.Group>
|
||||
* </AxoSelect.Content>
|
||||
* </AxoSelect.Root>
|
||||
* );
|
||||
* </AxoSelect.Group>
|
||||
* </AxoSelect.Content>
|
||||
* </AxoSelect.Root>
|
||||
* ```
|
||||
*
|
||||
* @see {@link https://www.radix-ui.com/primitives/docs/components/select | Select - Radix Docs}
|
||||
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/listbox/ | Button Pattern - ARIA Authoring Practices Guide}
|
||||
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/ | Select-Only Combobox Example - ARIA Authoring Practices Guide}
|
||||
* @see {@link https://w3c.github.io/aria/#listbox | `listbox` role - WAI-ARIA 1.3}
|
||||
* @see {@link https://w3c.github.io/aria/#select | `select` role - WAI-ARIA 1.3}
|
||||
* @see {@link https://w3c.github.io/aria/#combobox | `combobox` role - WAI-ARIA 1.3}
|
||||
*/
|
||||
export namespace AxoSelect {
|
||||
/**
|
||||
* Component: <AxoSelect.Root>
|
||||
* ---------------------------
|
||||
* <AxoSelect.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type RootProps = Readonly<{
|
||||
/**
|
||||
* The name of the select. Submitted with its owning form as part of a
|
||||
* name/value pair.
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* Associates the select with a `<form>` element by its id.
|
||||
*/
|
||||
form?: string;
|
||||
/**
|
||||
* Browser autocomplete hint (e.g. `"country"`, `"language"`).
|
||||
*/
|
||||
autoComplete?: string;
|
||||
/**
|
||||
* When `true`, prevents the user from interacting with select.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* When `true`, indicates that the user must select a value before the
|
||||
* owning form can be submitted.
|
||||
*/
|
||||
required?: boolean;
|
||||
/**
|
||||
* The controlled open state of the select.
|
||||
* Must be used in conjunction with `onOpenChange`.
|
||||
*/
|
||||
open?: boolean;
|
||||
/**
|
||||
* Event handler called when the open state of the select changes.
|
||||
*/
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/**
|
||||
* The controlled value of the select.
|
||||
* Should be used in conjunction with `onValueChange`.
|
||||
*/
|
||||
value: string | null;
|
||||
/**
|
||||
* Event handler called when the value changes.
|
||||
*/
|
||||
onValueChange: (value: string) => void;
|
||||
/**
|
||||
* Should be a `Trigger` and a `Content`.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Contains all the parts of a select.
|
||||
*
|
||||
* @example Notification sound picker
|
||||
* ```tsx
|
||||
* <AxoSelect.Root value={sound} onValueChange={setSound}>
|
||||
* <AxoSelect.Trigger placeholder="Choose a sound" />
|
||||
* <AxoSelect.Content>
|
||||
* <AxoSelect.Item value="note"><AxoSelect.ItemText>Note</AxoSelect.ItemText></AxoSelect.Item>
|
||||
* <AxoSelect.Item value="chord"><AxoSelect.ItemText>Chord</AxoSelect.ItemText></AxoSelect.Item>
|
||||
* <AxoSelect.Item value="none"><AxoSelect.ItemText>None</AxoSelect.ItemText></AxoSelect.Item>
|
||||
* </AxoSelect.Content>
|
||||
* </AxoSelect.Root>
|
||||
* ```
|
||||
*/
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
return (
|
||||
@@ -77,15 +125,33 @@ export namespace AxoSelect {
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = `${Namespace}.Root`;
|
||||
Root.displayName = 'AxoSelect.Root';
|
||||
|
||||
/**
|
||||
* Component: <AxoSelect.Trigger>
|
||||
* ---------------------------
|
||||
* <AxoSelect.Trigger>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Visual style of the trigger button.
|
||||
* - `default`: Filled secondary background (default).
|
||||
* - `floating`: Elevated with a drop shadow.
|
||||
* - `borderless`: Transparent, shows a background only on hover.
|
||||
*/
|
||||
export type TriggerVariant = 'default' | 'floating' | 'borderless';
|
||||
|
||||
/**
|
||||
* Width of the trigger button.
|
||||
* - `fit`: Shrinks to fit the selected value (default).
|
||||
* - `full`: Stretches to fill the container.
|
||||
*/
|
||||
export type TriggerWidth = 'fit' | 'full';
|
||||
|
||||
/**
|
||||
* When the dropdown chevron is shown.
|
||||
* - `always`: Always visible (default).
|
||||
* - `on-hover`: Only visible on hover/focus.
|
||||
*/
|
||||
export type TriggerChevron = 'always' | 'on-hover';
|
||||
|
||||
const baseTriggerStyles = tw(
|
||||
@@ -96,7 +162,7 @@ export namespace AxoSelect {
|
||||
'forced-colors:border'
|
||||
);
|
||||
|
||||
const TriggerVariants: Record<TriggerVariant, TailwindStyles> = {
|
||||
const TriggerVariants = variants<TriggerVariant>('AxoSelect.TriggerVariant', {
|
||||
default: tw(
|
||||
baseTriggerStyles,
|
||||
'bg-fill-secondary',
|
||||
@@ -114,33 +180,33 @@ export namespace AxoSelect {
|
||||
'enabled:hover:bg-fill-secondary',
|
||||
'enabled:active:bg-fill-secondary-pressed'
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const TriggerWidths: Record<TriggerWidth, TailwindStyles> = {
|
||||
const TriggerWidths = variants<TriggerWidth>('AxoSelect.TriggerWidth', {
|
||||
fit: tw(),
|
||||
full: tw('w-full'),
|
||||
};
|
||||
});
|
||||
|
||||
type TriggerChevronConfig = {
|
||||
chevronStyles: TailwindStyles;
|
||||
contentStyles: TailwindStyles;
|
||||
};
|
||||
|
||||
const baseContentStyles = tw('flex min-w-0 flex-1');
|
||||
|
||||
const TriggerChevrons: Record<TriggerChevron, TriggerChevronConfig> = {
|
||||
always: {
|
||||
chevronStyles: tw('ps-2 pe-2.5'),
|
||||
contentStyles: tw(baseContentStyles, 'py-[5px] ps-3'),
|
||||
},
|
||||
'on-hover': {
|
||||
chevronStyles: tw(
|
||||
const TriggerChevronStyles = variants<TriggerChevron>(
|
||||
'AxoSelect.TriggerChevron',
|
||||
{
|
||||
always: tw('ps-2 pe-2.5'),
|
||||
'on-hover': tw(
|
||||
'absolute inset-y-0 inset-e-0 w-9.5',
|
||||
'flex items-center justify-end pe-2',
|
||||
'opacity-0 group-enabled:group-hover:opacity-100 group-enabled:group-focus:opacity-100 group-data-[state=open]:opacity-100',
|
||||
'transition-opacity duration-150'
|
||||
),
|
||||
contentStyles: tw(
|
||||
}
|
||||
);
|
||||
|
||||
const baseContentStyles = tw('flex min-w-0 flex-1');
|
||||
|
||||
const TriggerChevronContentStyles = variants<TriggerChevron>(
|
||||
'AxoSelect.TriggerChevron',
|
||||
{
|
||||
always: tw(baseContentStyles, 'py-[5px] ps-3'),
|
||||
'on-hover': tw(
|
||||
baseContentStyles,
|
||||
'px-3 py-[5px]',
|
||||
'[--axo-select-trigger-mask-start:black]',
|
||||
@@ -153,14 +219,30 @@ export namespace AxoSelect {
|
||||
'mask-right rtl:mask-left',
|
||||
'[transition-property:--axo-select-trigger-mask-start] duration-150'
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export type TriggerProps = Readonly<{
|
||||
/**
|
||||
* Visual style of the button. Defaults to `default`.
|
||||
*/
|
||||
variant?: TriggerVariant;
|
||||
/**
|
||||
* Width of the button. Defaults to `fit`.
|
||||
*/
|
||||
width?: TriggerWidth;
|
||||
/**
|
||||
* When to show the dropdown chevron. Defaults to `always`.
|
||||
*/
|
||||
chevron?: TriggerChevron;
|
||||
/**
|
||||
* The content that will be rendered inside the `Trigger` when `value` is
|
||||
* `null`.
|
||||
*/
|
||||
placeholder: string;
|
||||
/**
|
||||
* Custom rendering of the selected value inside the trigger.
|
||||
*/
|
||||
children?: ReactNode;
|
||||
}>;
|
||||
|
||||
@@ -173,32 +255,37 @@ export namespace AxoSelect {
|
||||
const variant = props.variant ?? 'default';
|
||||
const width = props.width ?? 'fit';
|
||||
const chevron = props.chevron ?? 'always';
|
||||
const variantStyles = TriggerVariants[variant];
|
||||
const widthStyles = TriggerWidths[width];
|
||||
const chevronConfig = TriggerChevrons[chevron];
|
||||
return (
|
||||
<Select.Trigger className={tw(variantStyles, widthStyles)}>
|
||||
<div className={chevronConfig.contentStyles}>
|
||||
<Select.Trigger
|
||||
className={tw(TriggerVariants.get(variant), TriggerWidths.get(width))}
|
||||
>
|
||||
<div className={TriggerChevronContentStyles.get(chevron)}>
|
||||
<AxoBaseMenu.ItemText>
|
||||
<Select.Value placeholder={props.placeholder}>
|
||||
{props.children}
|
||||
</Select.Value>
|
||||
</AxoBaseMenu.ItemText>
|
||||
</div>
|
||||
<Select.Icon className={chevronConfig.chevronStyles}>
|
||||
<Select.Icon className={TriggerChevronStyles.get(chevron)}>
|
||||
<AxoSymbol.Icon symbol="chevron-down" size={14} label={null} />
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
);
|
||||
});
|
||||
|
||||
Trigger.displayName = `${Namespace}.Trigger`;
|
||||
Trigger.displayName = 'AxoSelect.Trigger';
|
||||
|
||||
/**
|
||||
* Component: <AxoSelect.Content>
|
||||
* ------------------------------
|
||||
* <AxoSelect.Content>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Positioning strategy for the dropdown popup.
|
||||
* - `item-aligned`: Overlays the list on the trigger, with the selected item
|
||||
* aligned to the trigger position (default).
|
||||
* - `dropdown`: Drops down from below the trigger like a standard dropdown.
|
||||
*/
|
||||
export type ContentPosition = 'item-aligned' | 'dropdown';
|
||||
|
||||
type ContentPositionConfig = {
|
||||
@@ -208,20 +295,29 @@ export namespace AxoSelect {
|
||||
sideOffset?: Select.SelectContentProps['sideOffset'];
|
||||
};
|
||||
|
||||
const ContentPositions: Record<ContentPosition, ContentPositionConfig> = {
|
||||
'item-aligned': {
|
||||
position: 'item-aligned',
|
||||
},
|
||||
dropdown: {
|
||||
position: 'popper',
|
||||
alignOffset: 0,
|
||||
collisionPadding: 6,
|
||||
sideOffset: 8,
|
||||
},
|
||||
};
|
||||
const ContentPositions = variants<ContentPosition, ContentPositionConfig>(
|
||||
'AxoSelect.ContentPosition',
|
||||
{
|
||||
'item-aligned': {
|
||||
position: 'item-aligned',
|
||||
},
|
||||
dropdown: {
|
||||
position: 'popper',
|
||||
alignOffset: 0,
|
||||
collisionPadding: 6,
|
||||
sideOffset: 8,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type ContentProps = Readonly<{
|
||||
/**
|
||||
* Positioning strategy for the popup. Defaults to `item-aligned`.
|
||||
*/
|
||||
position?: ContentPosition;
|
||||
/**
|
||||
* Should be `Item`, `Group`, `Label`, and/or `Separator` elements.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
@@ -231,7 +327,7 @@ export namespace AxoSelect {
|
||||
*/
|
||||
export const Content: FC<ContentProps> = memo(props => {
|
||||
const position = props.position ?? 'item-aligned';
|
||||
const positionConfig = ContentPositions[position];
|
||||
const positionConfig = ContentPositions.get(position);
|
||||
return (
|
||||
<Select.Portal>
|
||||
<AxoTheme.Inherit>
|
||||
@@ -269,18 +365,38 @@ export namespace AxoSelect {
|
||||
);
|
||||
});
|
||||
|
||||
Content.displayName = `${Namespace}.Content`;
|
||||
Content.displayName = 'AxoSelect.Content';
|
||||
|
||||
/**
|
||||
* Component: <AxoSelect.Item>
|
||||
* ---------------------------
|
||||
* <AxoSelect.Item>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ItemProps = Readonly<{
|
||||
/**
|
||||
* The value given as data when submitted with a name.
|
||||
*/
|
||||
value: string;
|
||||
/**
|
||||
* 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
|
||||
* `ItemText` part. Use this when the content is complex, or you have
|
||||
* non-textual content inside.
|
||||
*/
|
||||
textValue?: string;
|
||||
/**
|
||||
* Optional leading icon. If one item has an icon, prefer to give all items
|
||||
* an icon.
|
||||
*/
|
||||
symbol?: AxoSymbol.IconName;
|
||||
/**
|
||||
* Should be an `ItemText`, optionally followed by an
|
||||
* `ExperimentalItemBadge`.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
@@ -314,16 +430,23 @@ export namespace AxoSelect {
|
||||
);
|
||||
});
|
||||
|
||||
Item.displayName = `${Namespace}.Content`;
|
||||
Item.displayName = 'AxoSelect.Content';
|
||||
|
||||
/**
|
||||
* Component: <AxoSelect.ItemText>
|
||||
* <AxoSelect.ItemText>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ItemTextProps = Readonly<{
|
||||
/** The visible label for this item. Also shown inside the trigger when selected. */
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The textual part of the item. It should only contain the text you want to
|
||||
* see in the trigger when that item is selected. It should not be styled to
|
||||
* ensure correct positioning.
|
||||
*/
|
||||
export const ItemText: FC<ItemTextProps> = memo(props => {
|
||||
return (
|
||||
<AxoBaseMenu.ItemText>
|
||||
@@ -332,11 +455,11 @@ export namespace AxoSelect {
|
||||
);
|
||||
});
|
||||
|
||||
ItemText.displayName = `${Namespace}.ItemText`;
|
||||
ItemText.displayName = 'AxoSelect.ItemText';
|
||||
|
||||
/**
|
||||
* Component: <AxoSelect.ItemBadge>
|
||||
* --------------------------------
|
||||
* <AxoSelect.ItemBadge>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ExperimentalItemBadgeProps = Omit<
|
||||
@@ -344,6 +467,9 @@ export namespace AxoSelect {
|
||||
'size'
|
||||
>;
|
||||
|
||||
/**
|
||||
* A badge shown at the trailing edge of an item, typically for unread counts.
|
||||
*/
|
||||
export const ExperimentalItemBadge = memo(
|
||||
(props: ExperimentalItemBadgeProps) => {
|
||||
return (
|
||||
@@ -353,28 +479,31 @@ export namespace AxoSelect {
|
||||
value={props.value}
|
||||
max={props.max}
|
||||
maxDisplay={props.maxDisplay}
|
||||
aria-label={props['aria-label']}
|
||||
label={props.label}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ExperimentalItemBadge.displayName = `${Namespace}.ItemBadge`;
|
||||
ExperimentalItemBadge.displayName = 'AxoSelect.ItemBadge';
|
||||
|
||||
/**
|
||||
* Component: <AxoSelect.Group>
|
||||
* ----------------------------
|
||||
* <AxoSelect.Group>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type GroupProps = Readonly<{
|
||||
/**
|
||||
* Should be a `Label` followed by one or more `Item` elements.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Used to group multiple items.
|
||||
* Use in conjunction with {@link AxoSelect.Label to ensure good accessibility
|
||||
* via automatic labelling.
|
||||
* 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 (
|
||||
@@ -384,19 +513,22 @@ export namespace AxoSelect {
|
||||
);
|
||||
});
|
||||
|
||||
Group.displayName = `${Namespace}.Group`;
|
||||
Group.displayName = 'AxoSelect.Group';
|
||||
|
||||
/**
|
||||
* Component: <AxoSelect.Label>
|
||||
* ---------------------------
|
||||
* <AxoSelect.Label>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type LabelProps = Readonly<{
|
||||
/**
|
||||
* The text label for the group.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Used to render the label of a group. It won't be focusable using arrow keys.
|
||||
* Render the label of a group. It won't be focusable using arrow keys.
|
||||
*/
|
||||
export const Label: FC<LabelProps> = memo(props => {
|
||||
return (
|
||||
@@ -408,19 +540,19 @@ export namespace AxoSelect {
|
||||
);
|
||||
});
|
||||
|
||||
Label.displayName = `${Namespace}.Label`;
|
||||
Label.displayName = 'AxoSelect.Label';
|
||||
|
||||
/**
|
||||
* Component: <AxoSelect.Separator>
|
||||
* ---------------------------
|
||||
* <AxoSelect.Separator>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Used to visually separate items in the select.
|
||||
* Visually separate items in the select.
|
||||
*/
|
||||
export const Separator: FC = memo(() => {
|
||||
return <Select.Separator className={AxoBaseMenu.selectSeperatorStyles} />;
|
||||
});
|
||||
|
||||
Separator.displayName = `${Namespace}.Separator`;
|
||||
Separator.displayName = 'AxoSelect.Separator';
|
||||
}
|
||||
|
||||
@@ -1,21 +1,55 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { FC } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { Switch } from 'radix-ui';
|
||||
import { tw } from './tw.dom.tsx';
|
||||
import { AxoSymbol } from './AxoSymbol.dom.tsx';
|
||||
|
||||
const Namespace = 'AxoSwitch';
|
||||
|
||||
/**
|
||||
* A control that allows the user to toggle between checked and not checked.
|
||||
*
|
||||
* TODO(jamie): We need to figure out a better way to label these.
|
||||
*
|
||||
* @example Anatomy
|
||||
* ```tsx
|
||||
* <AxoSwitch.Root checked={checked} onCheckedChange={setChecked} />
|
||||
* ```
|
||||
*
|
||||
* @see {@link https://www.radix-ui.com/primitives/docs/components/switch | Switch - Radix Docs}
|
||||
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/switch/ | Switch Pattern - ARIA Authoring Practices Guide}
|
||||
* @see {@link https://w3c.github.io/aria/#switch | `switch` role - WAI-ARIA 1.3}
|
||||
*/
|
||||
export namespace AxoSwitch {
|
||||
/**
|
||||
* <AxoSwitch.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type RootProps = Readonly<{
|
||||
/** The controlled state of the switch. Must be used in conjunction with `onCheckedChange`. */
|
||||
checked: boolean;
|
||||
/** Event handler called when the state of the switch changes. */
|
||||
onCheckedChange: (nextChecked: boolean) => void;
|
||||
/** When true, prevents the user from interacting with the switch. */
|
||||
disabled?: boolean;
|
||||
/** When true, indicates that the user must check the switch before the owning form can be submitted. */
|
||||
required?: boolean;
|
||||
}>;
|
||||
|
||||
export const Root = memo((props: RootProps) => {
|
||||
/**
|
||||
* Contains all the parts of a switch. An input will also render when used
|
||||
* within a form to ensure events propagate correctly.
|
||||
*
|
||||
* @example Inside a label element
|
||||
* ```tsx
|
||||
* <label className={tw('flex items-center gap-3')}>
|
||||
* <span className={tw('grow')}>Allow multiple votes</span>
|
||||
* <AxoSwitch.Root checked={allowMultiple} onCheckedChange={setAllowMultiple} />
|
||||
* </label>
|
||||
* ```
|
||||
*/
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
return (
|
||||
<Switch.Root
|
||||
checked={props.checked}
|
||||
@@ -74,5 +108,5 @@ export namespace AxoSwitch {
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = `${Namespace}.Root`;
|
||||
Root.displayName = 'AxoSwitch.Root';
|
||||
}
|
||||
|
||||
+122
-39
@@ -4,7 +4,6 @@ import type { FC, JSX } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Direction } from 'radix-ui';
|
||||
import { VisuallyHidden } from 'react-aria';
|
||||
import type { TailwindStyles } from './tw.dom.tsx';
|
||||
import { tw } from './tw.dom.tsx';
|
||||
import {
|
||||
getAxoSymbolIcon,
|
||||
@@ -14,21 +13,44 @@ import type {
|
||||
AxoSymbolIconName,
|
||||
AxoSymbolInlineGlyphName,
|
||||
} from './_internal/AxoSymbolDefs.generated.std.ts';
|
||||
import { variants } from './_internal/variants.dom.tsx';
|
||||
|
||||
const { useDirection } = Direction;
|
||||
|
||||
const Namespace = 'AxoSymbol';
|
||||
|
||||
/**
|
||||
* Renders symbols from the Axo symbol font — either as a fixed-size block icon
|
||||
* or as an inline glyph that flows with surrounding text.
|
||||
*
|
||||
* @example Anatomy
|
||||
* ```tsx
|
||||
* // Fixed-size icon, e.g. in buttons or list items
|
||||
* <AxoSymbol.Icon size={20} symbol="check" label={null} />
|
||||
*
|
||||
* // Inline glyph, e.g. directional arrows inside button labels
|
||||
* <AxoSymbol.InlineGlyph symbol="arrow-end" label={null} />
|
||||
* ```
|
||||
*/
|
||||
export namespace AxoSymbol {
|
||||
const symbolStyles = tw('font-symbols select-none');
|
||||
const labelStyles = tw('select-none');
|
||||
/**
|
||||
* Stroke weight of the symbol.
|
||||
* - `300` – light
|
||||
* - `400` – regular (default)
|
||||
* - `700` – semibold
|
||||
*/
|
||||
export type Weight = 300 | 400 | 700;
|
||||
const WeightStyles = {
|
||||
|
||||
/**
|
||||
* useRenderSymbol()
|
||||
* --------------------------------------
|
||||
*/
|
||||
|
||||
const WeightStyles = variants<Weight>('AxoSymbol.Weight', {
|
||||
300: tw('font-light'),
|
||||
400: tw(),
|
||||
700: tw('font-semibold'),
|
||||
} as const satisfies Record<Weight, TailwindStyles>;
|
||||
});
|
||||
|
||||
/** @internal */
|
||||
function useRenderSymbol(
|
||||
glyph: string,
|
||||
label: string | null,
|
||||
@@ -37,11 +59,16 @@ export namespace AxoSymbol {
|
||||
return useMemo(() => {
|
||||
return (
|
||||
<>
|
||||
<span aria-hidden className={tw(symbolStyles, WeightStyles[weight])}>
|
||||
<span
|
||||
aria-hidden
|
||||
className={tw('font-symbols select-none', WeightStyles.get(weight))}
|
||||
>
|
||||
{glyph}
|
||||
</span>
|
||||
{label != null && (
|
||||
<VisuallyHidden className={labelStyles}>{label}</VisuallyHidden>
|
||||
<VisuallyHidden className={tw('select-none')}>
|
||||
{label}
|
||||
</VisuallyHidden>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -49,17 +76,52 @@ export namespace AxoSymbol {
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: <AxoSymbol.InlineGlyph>
|
||||
* --------------------------------------
|
||||
* <AxoSymbol.InlineGlyph>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* A symbol font name that can be used as an inline glyph. May have small
|
||||
* visual differences from the "icon" appearance to look better inline with
|
||||
* text.
|
||||
*
|
||||
* Note: Auto-generated from the symbol font.
|
||||
*
|
||||
*/
|
||||
export type InlineGlyphName = AxoSymbolInlineGlyphName;
|
||||
|
||||
export type InlineGlyphProps = Readonly<{
|
||||
/** The icon to render. */
|
||||
symbol: InlineGlyphName;
|
||||
/**
|
||||
* Accessible label for screen readers. Pass `null` if the glyph is purely
|
||||
* decorative and the surrounding context (Ex: a button's aria-label) already
|
||||
* conveys the meaning.
|
||||
*/
|
||||
label: string | null;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* An inline symbol that flows with surrounding text. Use when you want to
|
||||
* match the font size and don't care about the width of the icon.
|
||||
*
|
||||
* @example Within a labeled element
|
||||
* ```tsx
|
||||
* <button>
|
||||
* Expand items
|
||||
* <AxoSymbol.InlineGlyph symbol="arrow-down" label={null} />
|
||||
* </button>
|
||||
* ```
|
||||
*
|
||||
* @example Outside a labeled element
|
||||
* ```tsx
|
||||
* <p>
|
||||
* {"Then click on the "}
|
||||
* <AxoSymbol.InlineGlyph symbol="arrow-[end]" label="Next page" />
|
||||
* {" button"}
|
||||
* </p>
|
||||
* ```
|
||||
*/
|
||||
export const InlineGlyph: FC<InlineGlyphProps> = memo(props => {
|
||||
const direction = useDirection();
|
||||
const glyph = getAxoSymbolInlineGlyph(props.symbol, direction);
|
||||
@@ -67,37 +129,53 @@ export namespace AxoSymbol {
|
||||
return content;
|
||||
});
|
||||
|
||||
InlineGlyph.displayName = `${Namespace}.InlineGlyph`;
|
||||
InlineGlyph.displayName = 'AxoSymbol.InlineGlyph';
|
||||
|
||||
/**
|
||||
* Component: <AxoSymbol.Icon>
|
||||
* --------------------------------------
|
||||
* <AxoSymbol.Icon>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* A symbol font name that can be used as an icon. May have small
|
||||
* visual differences from the "inline glyph" appearance to look better as a
|
||||
* standalone icon.
|
||||
*
|
||||
* Note: Auto-generated from the symbol font.
|
||||
*/
|
||||
export type IconName = AxoSymbolIconName;
|
||||
|
||||
/** Available icon sizes in pixels. */
|
||||
export type IconSize = 12 | 14 | 16 | 18 | 20 | 24 | 36 | 48;
|
||||
|
||||
type IconSizeConfig = { size: number; fontSize: number };
|
||||
|
||||
const IconSizes: Record<IconSize, IconSizeConfig> = {
|
||||
12: { size: 12, fontSize: 10 },
|
||||
14: { size: 14, fontSize: 12 },
|
||||
16: { size: 16, fontSize: 14 },
|
||||
18: { size: 18, fontSize: 16 },
|
||||
20: { size: 20, fontSize: 18 },
|
||||
24: { size: 24, fontSize: 22 },
|
||||
36: { size: 36, fontSize: 34 },
|
||||
48: { size: 48, fontSize: 44 },
|
||||
};
|
||||
const IconSizes = variants<IconSize>('AxoSymbol.IconSize', {
|
||||
12: tw('size-[12px] text-[10px]'),
|
||||
14: tw('size-[14px] text-[12px]'),
|
||||
16: tw('size-[16px] text-[14px]'),
|
||||
18: tw('size-[18px] text-[16px]'),
|
||||
20: tw('size-[20px] text-[18px]'),
|
||||
24: tw('size-[24px] text-[22px]'),
|
||||
36: tw('size-[36px] text-[34px]'),
|
||||
48: tw('size-[48px] text-[44px]'),
|
||||
});
|
||||
|
||||
/** @testexport */
|
||||
export function _getAllIconSizes(): ReadonlyArray<IconSize> {
|
||||
return Object.keys(IconSizes).map(size => Number(size) as IconSize);
|
||||
return IconSizes.keys().map(size => Number(size) as IconSize);
|
||||
}
|
||||
|
||||
export type IconProps = Readonly<{
|
||||
/** Size of the icon in pixels. */
|
||||
size: IconSize;
|
||||
/** The icon to render. Automatically mirrored in RTL layouts. */
|
||||
symbol: IconName;
|
||||
/**
|
||||
* Accessible label for screen readers. Pass `null` if the icon is purely
|
||||
* decorative and the surrounding context (Ex: a button's aria-label) already
|
||||
* conveys the meaning.
|
||||
*/
|
||||
label: string | null;
|
||||
/** Stroke weight of the icon. Defaults to `400`. */
|
||||
weight?: Weight;
|
||||
}>;
|
||||
|
||||
@@ -105,27 +183,32 @@ export namespace AxoSymbol {
|
||||
'inline-flex size-[1em] shrink-0 items-center justify-center align-middle leading-none'
|
||||
);
|
||||
|
||||
/**
|
||||
* A fixed-size icon. Prefer using this when the width and height both matter.
|
||||
*
|
||||
* @example Within a labeled element
|
||||
* ```tsx
|
||||
* <button aria-label="Expand items">
|
||||
* <AxoSymbol.Icon size={20} symbol="arrow-down" label={null} />
|
||||
* </button>
|
||||
* ```
|
||||
*
|
||||
* @example Outside a labeled element
|
||||
* ```tsx
|
||||
* <AxoSymbol.Icon size={20} symbol="shield-check" label="Verified" />
|
||||
* ```
|
||||
*/
|
||||
export const Icon: FC<IconProps> = memo(props => {
|
||||
const config = IconSizes[props.size];
|
||||
const direction = useDirection();
|
||||
const weight = props.weight ?? 400;
|
||||
const glyph = getAxoSymbolIcon(props.symbol, direction);
|
||||
const content = useRenderSymbol(glyph, props.label, weight);
|
||||
|
||||
const style = useMemo(() => {
|
||||
return {
|
||||
width: config.size,
|
||||
height: config.size,
|
||||
fontSize: config.fontSize,
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
return (
|
||||
<span className={iconStyles} style={style}>
|
||||
<span className={tw(iconStyles, IconSizes.get(props.size))}>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
Icon.displayName = `${Namespace}.Icon`;
|
||||
Icon.displayName = 'AxoSymbol.Icon';
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ function TemplateInput(props: TemplateInputProps) {
|
||||
maxGraphemes={props.showCount ? MAX_GRAPHEMES_SHORT : 200}
|
||||
maxBytes={props.showCount ? MAX_BYTES_SHORT : 800}
|
||||
showCount={props.showCount}
|
||||
showClearWithLabel={props.showClear ? 'Clear' : null}
|
||||
showClear={props.showClear}
|
||||
sizing={props.sizing}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
|
||||
+146
-31
@@ -5,40 +5,104 @@ import type { FC, InputEvent, MouseEvent, ReactNode, RefObject } from 'react';
|
||||
import { mergeRefs } from '@react-aria/utils';
|
||||
import { AxoSymbol } from './AxoSymbol.dom.tsx';
|
||||
import { tw } from './tw.dom.tsx';
|
||||
import type { TailwindStyles } from './tw.dom.tsx';
|
||||
import { assert } from './_internal/assert.std.tsx';
|
||||
import { utf8 } from './_internal/utf8.std.ts';
|
||||
import {
|
||||
createStrictContext,
|
||||
useStrictContext,
|
||||
} from './_internal/StrictContext.dom.tsx';
|
||||
import { useAxoIntl } from './_internal/AxoIntl.dom.tsx';
|
||||
import { variants } from './_internal/variants.dom.tsx';
|
||||
|
||||
const Namespace = 'AxoTextField';
|
||||
|
||||
/**
|
||||
* A single-line text input with optional icons, action buttons, and
|
||||
* character/byte limiting.
|
||||
*
|
||||
* @example Anatomy
|
||||
* ```tsx
|
||||
* <AxoTextField.Root>
|
||||
* <AxoTextField.Input />
|
||||
* <AxoTextField.Separator />
|
||||
* <AxoTextField.Input />
|
||||
* <AxoTextField.Action />
|
||||
* </AxoTextField.Root>
|
||||
* ```
|
||||
* @see {@link https://w3c.github.io/aria/#textbox | `textbox` role - WAI-ARIA 1.3}
|
||||
* @see {@link https://w3c.github.io/aria/#group | `group` role - WAI-ARIA 1.3}
|
||||
*/
|
||||
export namespace AxoTextField {
|
||||
export type Width = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
export type Sizing = 'fixed' | 'grow' | 'fit';
|
||||
|
||||
/**
|
||||
* <AxoTextField.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/** @internal */
|
||||
type RootContextType = Readonly<{
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
}>;
|
||||
|
||||
const RootContext = createStrictContext<RootContextType>(`${Namespace}.Root`);
|
||||
/** @internal */
|
||||
const RootContext = createStrictContext<RootContextType>('AxoTextField.Root');
|
||||
|
||||
/**
|
||||
* The preferred width of the text field.
|
||||
*
|
||||
* TODO(jamie): Get real sizes from design
|
||||
*
|
||||
* - `xs` – 200px
|
||||
* - `sm` – 300px
|
||||
* - `md` – 400px
|
||||
* - `lg` – 500px
|
||||
* - `xl` – 600px
|
||||
* - `full` – stretches to fill the container (default)
|
||||
*
|
||||
* All sizes shrink to fit the container if it is narrower than the minimum.
|
||||
*/
|
||||
export type Width = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
|
||||
export type RootProps = Readonly<{
|
||||
/** Leading icon displayed before the input. */
|
||||
symbol?: AxoSymbol.IconName;
|
||||
/** Controls the width of the entire field. Defaults to `full`. */
|
||||
width?: Width;
|
||||
/** Disables all inputs and actions within the field. */
|
||||
disabled?: boolean;
|
||||
/** Makes all inputs within the field read-only. */
|
||||
readOnly?: boolean;
|
||||
/** Should be `Input`, `Action`, and/or `Separator` elements. */
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Container for the text field. Provides shared `disabled`/`readOnly` state
|
||||
* to child inputs and actions.
|
||||
*
|
||||
* @example Basic usage
|
||||
* ```tsx
|
||||
* <AxoTextField.Root>
|
||||
* <AxoTextField.Input
|
||||
* placeholder="First name"
|
||||
* value={value}
|
||||
* onValueChange={setValue}
|
||||
* maxGraphemes={26}
|
||||
* maxBytes={128}
|
||||
* showCount
|
||||
* showClear
|
||||
* />
|
||||
* </AxoTextField.Root>
|
||||
* ```
|
||||
*
|
||||
* @example Segmented field with icon and action
|
||||
* ```tsx
|
||||
* <AxoTextField.Root symbol="at">
|
||||
* <AxoTextField.Input placeholder="Username" sizing="grow" ... />
|
||||
* <AxoTextField.Separator />
|
||||
* <AxoTextField.Input placeholder="00" sizing="fit" ... />
|
||||
* <AxoTextField.Action label="Insert emoji" symbol="emoji" onClick={openEmojiPicker} />
|
||||
* </AxoTextField.Root>
|
||||
* ```
|
||||
*/
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
const { disabled, readOnly } = props;
|
||||
|
||||
@@ -56,21 +120,21 @@ export namespace AxoTextField {
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = `${Namespace}.Root`;
|
||||
Root.displayName = 'AxoTextField.Root';
|
||||
|
||||
/**
|
||||
* <AxoTextField.Group>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const GroupWidthStyles: Record<Width, TailwindStyles> = {
|
||||
const GroupWidthStyles = variants<Width>('AxoTextField.Width', {
|
||||
xs: tw('w-[calc-size(fit-content,min(max(200px,size),100%))]'),
|
||||
sm: tw('w-[calc-size(fit-content,min(max(300px,size),100%))]'),
|
||||
md: tw('w-[calc-size(fit-content,min(max(400px,size),100%))]'),
|
||||
lg: tw('w-[calc-size(fit-content,min(max(500px,size),100%))]'),
|
||||
xl: tw('w-[calc-size(fit-content,min(max(600px,size),100%))]'),
|
||||
full: tw('w-full'),
|
||||
};
|
||||
});
|
||||
|
||||
/** @internal */
|
||||
type GroupProps = Readonly<{
|
||||
@@ -86,7 +150,7 @@ export namespace AxoTextField {
|
||||
className={tw(
|
||||
'group flex items-stretch',
|
||||
'overflow-hidden',
|
||||
GroupWidthStyles[props.width],
|
||||
GroupWidthStyles.get(props.width),
|
||||
'curved-lg bg-fill-primary',
|
||||
'border-[0.5px] border-border-primary',
|
||||
'shadow-elevation-0 shadow-no-outline',
|
||||
@@ -105,38 +169,57 @@ export namespace AxoTextField {
|
||||
);
|
||||
});
|
||||
|
||||
Group.displayName = `${Namespace}.Group`;
|
||||
Group.displayName = 'AxoTextField.Group';
|
||||
|
||||
/**
|
||||
* <AxoTextField.Input>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* How an `Input` sizes itself within the field group.
|
||||
* - `fixed`: Takes up all remaining space (default).
|
||||
* - `grow`: Expands with typed content, up to available space.
|
||||
* - `fit`: Shrinks to fit typed content, useful for segmented fields.
|
||||
*/
|
||||
export type Sizing = 'fixed' | 'grow' | 'fit';
|
||||
|
||||
export type InputProps = Readonly<{
|
||||
/** Ref to the underlying `<input>` element. */
|
||||
ref?: RefObject<HTMLInputElement | null>;
|
||||
|
||||
/** Provide your own id for the `<input>` to target with a `<label>`. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/** Form field name for native form submissions. */
|
||||
name?: string;
|
||||
/** Placeholder text shown when the input is empty. */
|
||||
placeholder: string;
|
||||
|
||||
/** How the input sizes itself within the field group. Defaults to `fixed`. */
|
||||
sizing?: Sizing;
|
||||
|
||||
/** Controlled value of the input. */
|
||||
value: string;
|
||||
/** Called with the new value on every change. */
|
||||
onValueChange: (value: string) => void;
|
||||
|
||||
/** Maximum number of Unicode grapheme clusters allowed. */
|
||||
maxGraphemes: number;
|
||||
/** Maximum number of UTF-8 bytes allowed. Should be ~4x the number of `maxGraphemes`. */
|
||||
maxBytes: number;
|
||||
|
||||
/** Shows a remaining-character counter that appears as the limit is approached. */
|
||||
showCount?: boolean;
|
||||
showClearWithLabel?: string | null;
|
||||
|
||||
/** Shows a clear button when the input has a value. */
|
||||
showClear?: boolean;
|
||||
/** Marks the input as required for form validation. */
|
||||
required?: boolean;
|
||||
/** Disables this input. Also disabled if `Root` has `disabled` set. */
|
||||
disabled?: boolean;
|
||||
/** Makes this input read-only. Also read-only if `Root` has `readOnly` set. */
|
||||
readOnly?: boolean;
|
||||
/** Focuses the input on mount. */
|
||||
autoFocus?: boolean;
|
||||
/** Enables or disables browser spell checking. */
|
||||
spellCheck?: boolean;
|
||||
}>;
|
||||
|
||||
/** The text input field. Must be placed inside `Root`. */
|
||||
export const Input: FC<InputProps> = memo(props => {
|
||||
const { onValueChange, maxBytes, maxGraphemes } = props;
|
||||
const context = useStrictContext(RootContext);
|
||||
@@ -259,13 +342,12 @@ export namespace AxoTextField {
|
||||
maxGraphemes={props.maxGraphemes}
|
||||
/>
|
||||
)}
|
||||
{props.showClearWithLabel != null && (
|
||||
{props.showClear && (
|
||||
<Clear
|
||||
inputRef={inputRef}
|
||||
inputId={inputId}
|
||||
value={props.value}
|
||||
onValueChange={onValueChange}
|
||||
label={props.showClearWithLabel}
|
||||
disabled={disabled || readOnly}
|
||||
/>
|
||||
)}
|
||||
@@ -273,13 +355,14 @@ export namespace AxoTextField {
|
||||
);
|
||||
});
|
||||
|
||||
Input.displayName = `${Namespace}.Input`;
|
||||
Input.displayName = 'AxoTextField.Input';
|
||||
|
||||
/**
|
||||
* <AxoTextField.Icon>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/** @internal */
|
||||
type IconProps = Readonly<{
|
||||
symbol: AxoSymbol.IconName;
|
||||
}>;
|
||||
@@ -298,7 +381,7 @@ export namespace AxoTextField {
|
||||
);
|
||||
});
|
||||
|
||||
Icon.displayName = `${Namespace}.Icon`;
|
||||
Icon.displayName = 'AxoTextField.Icon';
|
||||
|
||||
/**
|
||||
* <AxoTextField.Count>
|
||||
@@ -308,6 +391,7 @@ export namespace AxoTextField {
|
||||
const SHOW_REMAINING_COUNT_THRESHOLD = 0.5;
|
||||
const WARN_REMAINING_COUNT_THRESHOLD = 0.25;
|
||||
|
||||
/** @internal */
|
||||
type CountProps = Readonly<{
|
||||
value: string;
|
||||
maxBytes: number;
|
||||
@@ -364,17 +448,17 @@ export namespace AxoTextField {
|
||||
);
|
||||
});
|
||||
|
||||
Count.displayName = `${Namespace}.Count`;
|
||||
Count.displayName = 'AxoTextField.Count';
|
||||
|
||||
/**
|
||||
* <AxoTextField.Clear>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/** @internal */
|
||||
type ClearProps = Readonly<{
|
||||
inputRef: RefObject<HTMLInputElement | null>;
|
||||
inputId: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
disabled: boolean;
|
||||
@@ -383,6 +467,7 @@ export namespace AxoTextField {
|
||||
/** @internal */
|
||||
const Clear: FC<ClearProps> = memo(props => {
|
||||
const { inputRef, value, onValueChange } = props;
|
||||
const intl = useAxoIntl();
|
||||
|
||||
const handleClear = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
@@ -400,7 +485,7 @@ export namespace AxoTextField {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={props.label}
|
||||
aria-label={intl.get('AxoTextField.Clear')}
|
||||
aria-controls={props.inputId}
|
||||
className={tw(
|
||||
'z-10',
|
||||
@@ -428,7 +513,7 @@ export namespace AxoTextField {
|
||||
);
|
||||
});
|
||||
|
||||
Clear.displayName = `${Namespace}.Clear`;
|
||||
Clear.displayName = 'AxoTextField.Clear';
|
||||
|
||||
/**
|
||||
* <AxoTextField.Action>
|
||||
@@ -436,12 +521,28 @@ export namespace AxoTextField {
|
||||
*/
|
||||
|
||||
export type ActionProps = Readonly<{
|
||||
/** Accessible label for the button describing the action to be taken, not the icon. */
|
||||
label: string;
|
||||
/** Icon to display inside the button. */
|
||||
symbol: AxoSymbol.IconName;
|
||||
/** Called when the button is clicked. */
|
||||
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
/** Overrides the `disabled` state from `Root` for this button only. */
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* An icon button placed inside a `Root`, typically used for supplementary
|
||||
* actions like inserting an emoji or opening a menu.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AxoTextField.Root>
|
||||
* <AxoTextField.Input ... />
|
||||
* <AxoTextField.Action label="Insert emoji" symbol="emoji" onClick={openEmojiPicker} />
|
||||
* </AxoTextField.Root>
|
||||
* ```
|
||||
*/
|
||||
export const Action: FC<ActionProps> = memo(props => {
|
||||
const { onClick } = props;
|
||||
const context = useStrictContext(RootContext);
|
||||
@@ -454,9 +555,11 @@ export namespace AxoTextField {
|
||||
const handleClick = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
if (!disabled) {
|
||||
onClick?.(event);
|
||||
if (disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onClick?.(event);
|
||||
},
|
||||
[disabled, onClick]
|
||||
);
|
||||
@@ -488,13 +591,25 @@ export namespace AxoTextField {
|
||||
);
|
||||
});
|
||||
|
||||
Action.displayName = `${Namespace}.Action`;
|
||||
Action.displayName = 'AxoTextField.Action';
|
||||
|
||||
/**
|
||||
* <AxoTextField.Separator>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* A vertical divider between segments in a multi-input field.
|
||||
*
|
||||
* @example Username + discriminator
|
||||
* ```tsx
|
||||
* <AxoTextField.Root symbol="at">
|
||||
* <AxoTextField.Input placeholder="Username" sizing="grow" ... />
|
||||
* <AxoTextField.Separator />
|
||||
* <AxoTextField.Input placeholder="00" sizing="fit" ... />
|
||||
* </AxoTextField.Root>
|
||||
* ```
|
||||
*/
|
||||
export const Separator: FC = memo(() => {
|
||||
return (
|
||||
<span className={tw('flex py-2 ps-3 pe-2')}>
|
||||
@@ -511,5 +626,5 @@ export namespace AxoTextField {
|
||||
);
|
||||
});
|
||||
|
||||
Separator.displayName = `${Namespace}.Separator`;
|
||||
Separator.displayName = 'AxoTextField.Separator';
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ function AxoDialogTest() {
|
||||
<AxoDialog.Content size="md" escape="cancel-is-noop">
|
||||
<AxoDialog.Header>
|
||||
<AxoDialog.Title>AxoDialog</AxoDialog.Title>
|
||||
<AxoDialog.Close aria-label="Close" />
|
||||
<AxoDialog.Close />
|
||||
</AxoDialog.Header>
|
||||
<AxoDialog.Body>
|
||||
<p>Lorem ipsum, dolor sit amet consectetur adipisicing elit.</p>
|
||||
|
||||
+65
-11
@@ -3,55 +3,109 @@
|
||||
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { memo, createContext, useContext } from 'react';
|
||||
import type { TailwindStyles } from './tw.dom.tsx';
|
||||
import { tw } from './tw.dom.tsx';
|
||||
import { variants } from './_internal/variants.dom.tsx';
|
||||
|
||||
const Namespace = 'AxoTheme';
|
||||
|
||||
/**
|
||||
* Controls the color scheme applied to a subtree.
|
||||
*
|
||||
* Use {@link Override} at section boundaries to set or force a color scheme,
|
||||
* and {@link Inherit} inside portals to re-apply the parent's scheme across
|
||||
* the portal boundary.
|
||||
*
|
||||
* @example Anatomy
|
||||
* ```tsx
|
||||
* <AxoTheme.Override theme="force-dark">
|
||||
* <AxoTheme.Inherit>
|
||||
* {/* portal content inherits force-dark *\/}
|
||||
* </AxoTheme.Inherit>
|
||||
* </AxoTheme.Override>
|
||||
* ```
|
||||
*/
|
||||
export namespace AxoTheme {
|
||||
/**
|
||||
* <AxoTheme.Overide>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* The color scheme to apply to a subtree.
|
||||
* - `auto`: Reverts to the app's setting, which inherits from the OS (default).
|
||||
* - `force-light`: Always light, regardless of system setting.
|
||||
* - `force-dark`: Always dark, regardless of system setting.
|
||||
*/
|
||||
export type ThemeOverride = 'force-light' | 'force-dark' | 'auto';
|
||||
|
||||
const ThemeOverrides: Record<ThemeOverride, TailwindStyles> = {
|
||||
const ThemeOverrides = variants<ThemeOverride>('AxoTheme.ThemeOverride', {
|
||||
'force-light': tw('scheme-light'),
|
||||
'force-dark': tw('scheme-dark'),
|
||||
auto: tw('scheme-light dark:scheme-dark'),
|
||||
};
|
||||
});
|
||||
|
||||
/** @internal */
|
||||
const ThemeOverrideContext = createContext<ThemeOverride>('auto');
|
||||
|
||||
/**
|
||||
* <AxoTheme.Overide>
|
||||
* ------------------
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type OverrideProps = Readonly<{
|
||||
/** The color scheme to apply. */
|
||||
theme: ThemeOverride;
|
||||
/** The subtree to apply the color scheme to. */
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Sets the color scheme for a subtree. Use at section boundaries where the
|
||||
* theme should differ from the rest of the page (e.g. always-dark overlays).
|
||||
*
|
||||
* @example Force dark theme in a calling UI
|
||||
* ```tsx
|
||||
* <AxoTheme.Override theme="force-dark">
|
||||
* <CallingControls />
|
||||
* </AxoTheme.Override>
|
||||
* ```
|
||||
*/
|
||||
export const Override: FC<OverrideProps> = memo(props => {
|
||||
return (
|
||||
<ThemeOverrideContext.Provider value={props.theme}>
|
||||
<div className={ThemeOverrides[props.theme]}>{props.children}</div>
|
||||
<div className={ThemeOverrides.get(props.theme)}>{props.children}</div>
|
||||
</ThemeOverrideContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
Override.displayName = `${Namespace}.Override`;
|
||||
Override.displayName = 'AxoTheme.Override';
|
||||
|
||||
/**
|
||||
* <AxoTheme.Inherit>
|
||||
* ------------------
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type InheritProps = Readonly<{
|
||||
/** The portal content that should inherit the parent's color scheme. */
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Re-applies the nearest {@link Override}'s color scheme to a subtree.
|
||||
* Required inside portals, since portal content renders outside the DOM
|
||||
* tree and won't automatically inherit CSS from the parent.
|
||||
*
|
||||
* @example Wrap portal content so it inherits the active theme
|
||||
* ```tsx
|
||||
* <Dialog.Portal>
|
||||
* <AxoTheme.Inherit>
|
||||
* <Dialog.Content>...</Dialog.Content>
|
||||
* </AxoTheme.Inherit>
|
||||
* </Dialog.Portal>
|
||||
* ```
|
||||
*/
|
||||
export const Inherit: FC<InheritProps> = memo(props => {
|
||||
const theme = useContext(ThemeOverrideContext);
|
||||
return <div className={ThemeOverrides[theme]}>{props.children}</div>;
|
||||
return <div className={ThemeOverrides.get(theme)}>{props.children}</div>;
|
||||
});
|
||||
|
||||
Inherit.displayName = `${Namespace}.Inherit`;
|
||||
Inherit.displayName = 'AxoTheme.Inherit';
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ export function InDialog(): JSX.Element {
|
||||
<AxoDialog.Content size="md" escape="cancel-is-destructive">
|
||||
<AxoDialog.Header>
|
||||
<AxoDialog.Title>Title</AxoDialog.Title>
|
||||
<AxoDialog.Close aria-label="Close" />
|
||||
<AxoDialog.Close />
|
||||
</AxoDialog.Header>
|
||||
<AxoDialog.Body>
|
||||
<div className={tw('flex flex-col items-center-safe gap-50 py-50')}>
|
||||
|
||||
+174
-100
@@ -19,13 +19,29 @@ import {
|
||||
} from './_internal/ariaRoles.dom.tsx';
|
||||
import { isTestOrMockEnvironment } from '../environment.std.ts';
|
||||
import { AxoTheme } from './AxoTheme.dom.tsx';
|
||||
import { variants } from './_internal/variants.dom.tsx';
|
||||
|
||||
const { useDirection } = Direction;
|
||||
|
||||
const Namespace = 'AxoTooltip';
|
||||
|
||||
type PhysicalDirection = 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
/**
|
||||
* A popup that displays information related to an element when the element
|
||||
* receives keyboard focus or the mouse hovers over it.
|
||||
*
|
||||
* @example Anatomy
|
||||
* ```tsx
|
||||
* <AxoTooltip.Provider>
|
||||
* <AxoTooltip.CollisionBoundary>
|
||||
* <AxoTooltip.Root>
|
||||
* <button/>
|
||||
* </AxoTooltip.Root>
|
||||
* </AxoTooltip.CollisionBoundary>
|
||||
* </AxoTooltip.Provider>
|
||||
* ```
|
||||
*
|
||||
* @see {@link https://www.radix-ui.com/primitives/docs/components/tooltip | Tooltip - Radix Docs}
|
||||
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/ | Tooltip Pattern - ARIA Authoring Practices Guide}
|
||||
* @see {@link https://w3c.github.io/aria/#tooltip | `tooltip` role - WAI-ARIA 1.3}
|
||||
*/
|
||||
export namespace AxoTooltip {
|
||||
/**
|
||||
* The duration from when the mouse enters a tooltip trigger until the
|
||||
@@ -37,10 +53,10 @@ export namespace AxoTooltip {
|
||||
*/
|
||||
export type Delay = 'auto' | 'none';
|
||||
|
||||
const Delays: Record<Delay, number> = {
|
||||
const Delays = variants<Delay, number>('AxoTooltip.Delay', {
|
||||
auto: 700,
|
||||
none: 0,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* How much time the user has to enter another tooltip trigger without
|
||||
@@ -50,10 +66,113 @@ export namespace AxoTooltip {
|
||||
*/
|
||||
export type SkipDelay = 'auto' | 'never';
|
||||
|
||||
const SkipDelays: Record<SkipDelay, number> = {
|
||||
const SkipDelays = variants<SkipDelay, number>('AxoTooltip.SkipDelay', {
|
||||
auto: 300,
|
||||
never: 0,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* <AxoTooltip.Provider>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ProviderProps = Readonly<{
|
||||
/** The duration from when the mouse enters a tooltip trigger until the tooltip opens. */
|
||||
delay?: Delay;
|
||||
/** How much time a user has to enter another trigger without incurring a delay again. */
|
||||
skipDelay?: SkipDelay;
|
||||
/** The subtree of components that can use tooltips. */
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/** Wraps your app to provide global functionality to your tooltips. */
|
||||
export const Provider: FC<ProviderProps> = memo(props => {
|
||||
const { delay = 'auto', skipDelay = 'auto' } = props;
|
||||
return (
|
||||
<Tooltip.Provider
|
||||
delayDuration={Delays.get(delay)}
|
||||
skipDelayDuration={SkipDelays.get(skipDelay)}
|
||||
>
|
||||
{props.children}
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
Provider.displayName = 'AxoTooltip.Provider';
|
||||
|
||||
/**
|
||||
* <AxoTooltip.CollisionBoundary>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const DEFAULT_COLLISION_PADDING = 8;
|
||||
|
||||
/** @internal */
|
||||
type CollisionBoundaryType = Readonly<{
|
||||
elements: Array<Element | null>;
|
||||
padding: number;
|
||||
}>;
|
||||
|
||||
/** @internal */
|
||||
const CollisionBoundaryContext = createContext<CollisionBoundaryType>({
|
||||
elements: [],
|
||||
padding: DEFAULT_COLLISION_PADDING,
|
||||
});
|
||||
|
||||
export type CollisionBoundaryProps = Readonly<{
|
||||
/**
|
||||
* The element used as the collision boundary. By default this is the viewport,
|
||||
* though you can provide additional element(s) to be included in this check.
|
||||
*/
|
||||
boundary: Element | null;
|
||||
/**
|
||||
* The distance in pixels from the boundary edges where collision detection
|
||||
* should occur. Accepts a number (same for all sides).
|
||||
*/
|
||||
padding?: number;
|
||||
/** The subtree of components that share this collision boundary. */
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Constrains tooltip positioning to stay within a specific element,
|
||||
* overriding any parent boundary.
|
||||
* @example
|
||||
* ```tsx
|
||||
* const [boundary, setBoundary] = useState<HTMLElement | null>(null);
|
||||
* <AxoTooltip.CollisionBoundary boundary={boundary}>
|
||||
* <div ref={setBoundary}>
|
||||
* <AxoTooltip.Root label="Hello">
|
||||
* <button />
|
||||
* </AxoTooltip.Root>
|
||||
* </div>
|
||||
* </AxoTooltip.CollisionBoundary>
|
||||
* ```
|
||||
*/
|
||||
export const CollisionBoundary: FC<CollisionBoundaryProps> = memo(props => {
|
||||
const { boundary, padding } = props;
|
||||
const context = useContext(CollisionBoundaryContext);
|
||||
|
||||
const value = useMemo((): CollisionBoundaryType => {
|
||||
return {
|
||||
elements: [...context.elements, boundary],
|
||||
padding: padding ?? DEFAULT_COLLISION_PADDING, // Always reset to default
|
||||
};
|
||||
}, [context, boundary, padding]);
|
||||
|
||||
return (
|
||||
<CollisionBoundaryContext.Provider value={value}>
|
||||
{props.children}
|
||||
</CollisionBoundaryContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
CollisionBoundary.displayName = 'AxoTooltip.CollisionBoundary';
|
||||
|
||||
/**
|
||||
* <AxoTooltip.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* The preferred side of the trigger to render against when open.
|
||||
@@ -75,95 +194,25 @@ export namespace AxoTooltip {
|
||||
*/
|
||||
export type Align = 'center' | 'force-start' | 'force-end';
|
||||
|
||||
const Aligns: Record<Align, Tooltip.TooltipContentProps['align']> = {
|
||||
type AlignValue = Tooltip.TooltipContentProps['align'];
|
||||
|
||||
const Aligns = variants<Align, AlignValue>('AxoTooltip.Align', {
|
||||
center: 'center',
|
||||
'force-start': 'start',
|
||||
'force-end': 'end',
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* The format used to display a timestamp alongside the tooltip label.
|
||||
*
|
||||
* TODO(jamie): Need to spec timestamp formats.
|
||||
*/
|
||||
export type ExperimentalTimestampFormat = 'testing-only';
|
||||
|
||||
/**
|
||||
* Component: <AxoTooltip.Provider>
|
||||
* --------------------------------
|
||||
*/
|
||||
|
||||
export type ProviderProps = Readonly<{
|
||||
delay?: Delay;
|
||||
skipDelay?: SkipDelay;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
export const Provider: FC<ProviderProps> = memo(props => {
|
||||
const { delay = 'auto', skipDelay = 'auto' } = props;
|
||||
|
||||
const delayDuration = useMemo(() => {
|
||||
return Delays[delay];
|
||||
}, [delay]);
|
||||
|
||||
const skipDelayDuration = useMemo(() => {
|
||||
return SkipDelays[skipDelay];
|
||||
}, [skipDelay]);
|
||||
|
||||
return (
|
||||
<Tooltip.Provider
|
||||
delayDuration={delayDuration}
|
||||
skipDelayDuration={skipDelayDuration}
|
||||
>
|
||||
{props.children}
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
Provider.displayName = `${Namespace}.Provider`;
|
||||
|
||||
/**
|
||||
* Component: <AxoTooltip.CollisionBoundary>
|
||||
* -----------------------------------------
|
||||
*/
|
||||
|
||||
const DEFAULT_COLLISION_PADDING = 8;
|
||||
|
||||
type CollisionBoundaryType = Readonly<{
|
||||
elements: Array<Element | null>;
|
||||
padding: number;
|
||||
}>;
|
||||
const CollisionBoundaryContext = createContext<CollisionBoundaryType>({
|
||||
elements: [],
|
||||
padding: DEFAULT_COLLISION_PADDING,
|
||||
});
|
||||
|
||||
export type CollisionBoundaryProps = Readonly<{
|
||||
boundary: Element | null;
|
||||
padding?: number;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
export const CollisionBoundary: FC<CollisionBoundaryProps> = memo(props => {
|
||||
const { boundary, padding } = props;
|
||||
const context = useContext(CollisionBoundaryContext);
|
||||
|
||||
const value = useMemo((): CollisionBoundaryType => {
|
||||
return {
|
||||
elements: [...context.elements, boundary],
|
||||
padding: padding ?? DEFAULT_COLLISION_PADDING, // Always reset to default
|
||||
};
|
||||
}, [context, boundary, padding]);
|
||||
|
||||
return (
|
||||
<CollisionBoundaryContext.Provider value={value}>
|
||||
{props.children}
|
||||
</CollisionBoundaryContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
CollisionBoundary.displayName = `${Namespace}.CollisionBoundary`;
|
||||
|
||||
/**
|
||||
* Component: <AxoTooltip.Root>
|
||||
* ----------------------------
|
||||
*/
|
||||
/** @internal */
|
||||
type PhysicalDirection = 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
/** @internal */
|
||||
function generateTooltipArrowPath(): string {
|
||||
let path = '';
|
||||
path += 'M 0 0'; // start at top left
|
||||
@@ -182,13 +231,19 @@ export namespace AxoTooltip {
|
||||
const TOOLTIP_ARROW_HEIGHT = 6;
|
||||
|
||||
export type RootConfigProps = Readonly<{
|
||||
/** Override the duration given to the `Provider` to customise the open delay for a specific tooltip. */
|
||||
delay?: Delay;
|
||||
/** The preferred side of the trigger to render against when open. Will be reversed when collisions occur. */
|
||||
side?: Side;
|
||||
/** The preferred alignment against the trigger. May change when collisions occur. */
|
||||
align?: Align;
|
||||
/** The primary text content of the tooltip. */
|
||||
label: ReactNode;
|
||||
// TODO(jamie): Need to spec timestamp formats
|
||||
/** An additional timestamp to display after the label. */
|
||||
experimentalTimestamp?: number | null;
|
||||
/** The timestamp format to use when a timestamp has been provided. */
|
||||
experimentalTimestampFormat?: ExperimentalTimestampFormat;
|
||||
/** An supplementary keyboard shortcut to display after the label for the button that the tooltip wraps. */
|
||||
keyboardShortcut?: string | null;
|
||||
}>;
|
||||
|
||||
@@ -200,13 +255,32 @@ export namespace AxoTooltip {
|
||||
* a visual affordance.
|
||||
*/
|
||||
tooltipRepeatsTriggerAccessibleName?: boolean;
|
||||
/** The trigger element that activates the tooltip on hover/focus. Must be a widget with an accessible name. */
|
||||
children: ReactNode;
|
||||
/** @private exported for stories only */
|
||||
__FORCE_OPEN?: boolean;
|
||||
}>;
|
||||
|
||||
const rootDisplayName = `${Namespace}.Root`;
|
||||
|
||||
/**
|
||||
* Wraps a trigger element and shows a tooltip on hover/focus.
|
||||
*
|
||||
* The child must be a focusable widget with an accessible name that does
|
||||
* not duplicate the tooltip label.
|
||||
*
|
||||
* @example Tooltip repeats the label of the button to provide a visual label
|
||||
* ```tsx
|
||||
* <AxoTooltip.Root label="Send message" tooltipRepeatsTriggerAccessibleName>
|
||||
* <button aria-label="Send message"/>
|
||||
* </AxoTooltip.Root>
|
||||
* ```
|
||||
*
|
||||
* @example Tooltip provides supplemental info to some button with an already visible label
|
||||
* ```tsx
|
||||
* <AxoTooltip.Root label="Already in a call">
|
||||
* <button disabled>Join Call</button>
|
||||
* </AxoTooltip.Root>
|
||||
* ```
|
||||
*/
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
const {
|
||||
delay,
|
||||
@@ -234,7 +308,7 @@ export namespace AxoTooltip {
|
||||
}, [side]);
|
||||
|
||||
const delayDuration = useMemo(() => {
|
||||
return delay != null ? Delays[delay] : undefined;
|
||||
return delay != null ? Delays.get(delay) : undefined;
|
||||
}, [delay]);
|
||||
|
||||
const formattedTimestamp = useMemo(() => {
|
||||
@@ -253,16 +327,16 @@ export namespace AxoTooltip {
|
||||
if (isTestOrMockEnvironment()) {
|
||||
assert(
|
||||
triggerRef.current instanceof HTMLElement,
|
||||
`${rootDisplayName} child must forward ref`
|
||||
`<AxoTooltip.Root> child must forward ref`
|
||||
);
|
||||
assert(
|
||||
isAriaWidgetRole(getElementAriaRole(triggerRef.current)),
|
||||
`${rootDisplayName} child must have a widget role like 'button'`
|
||||
`<AxoTooltip.Root> child must have a widget role like 'button'`
|
||||
);
|
||||
const triggerName = computeAccessibleName(triggerRef.current);
|
||||
assert(
|
||||
triggerName !== '',
|
||||
`${rootDisplayName} child must have an accessible name`
|
||||
`<AxoTooltip.Root> child must have an accessible name`
|
||||
);
|
||||
|
||||
if (props.tooltipRepeatsTriggerAccessibleName) {
|
||||
@@ -271,7 +345,7 @@ export namespace AxoTooltip {
|
||||
|
||||
assert(
|
||||
triggerName !== props.label,
|
||||
`${rootDisplayName} label must not repeat child trigger's accessible name. ` +
|
||||
`<AxoTooltip.Root> label must not repeat child trigger's accessible name. ` +
|
||||
'Use the tooltipRepeatsTriggerAccessibleName prop if you would ' +
|
||||
'like to make the tooltip presentational only.'
|
||||
);
|
||||
@@ -290,7 +364,7 @@ export namespace AxoTooltip {
|
||||
<AxoTheme.Inherit>
|
||||
<Tooltip.Content
|
||||
side={physicalDirection}
|
||||
align={Aligns[align]}
|
||||
align={Aligns.get(align)}
|
||||
sideOffset={6}
|
||||
arrowPadding={14}
|
||||
collisionBoundary={collisionBoundary.elements}
|
||||
@@ -356,5 +430,5 @@ export namespace AxoTooltip {
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = rootDisplayName;
|
||||
Root.displayName = 'AxoTooltip.Root';
|
||||
}
|
||||
|
||||
@@ -5,21 +5,31 @@ import type { RefCallback } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { createStrictContext, useStrictContext } from './StrictContext.dom.tsx';
|
||||
|
||||
/** @internal */
|
||||
type AriaLabellingContextType = Readonly<{
|
||||
labelRef: RefCallback<HTMLElement>;
|
||||
descriptionRef: RefCallback<HTMLElement>;
|
||||
}>;
|
||||
|
||||
/** @internal */
|
||||
const AriaLabellingContext = createStrictContext<AriaLabellingContextType>(
|
||||
'AriaLabellingContext.Provider'
|
||||
);
|
||||
|
||||
export type CreateAriaLabellingContextResult = Readonly<{
|
||||
/** The labelling context to pass to `AriaLabellingProvider`. */
|
||||
context: AriaLabellingContextType;
|
||||
/** The `id` of the label element, once mounted. `undefined` if no label has been registered. */
|
||||
labelId: string | undefined;
|
||||
/** The `id` of the description element, once mounted. `undefined` if none has been registered. */
|
||||
descriptionId: string | undefined;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Creates a labelling context that captures element IDs from nested label and
|
||||
* description refs. Use the returned `labelId`/`descriptionId` to wire up
|
||||
* `aria-labelledby`/`aria-describedby` on the containing widget.
|
||||
*/
|
||||
export function useCreateAriaLabellingContext(): CreateAriaLabellingContextResult {
|
||||
const [labelId, setLabelId] = useState<string | undefined>();
|
||||
const [descriptionId, setDescriptionId] = useState<string | undefined>();
|
||||
@@ -39,8 +49,14 @@ export function useCreateAriaLabellingContext(): CreateAriaLabellingContextResul
|
||||
return { context, labelId, descriptionId };
|
||||
}
|
||||
|
||||
/** Context provider. Pass the `context` returned by `useCreateAriaLabellingContext`. */
|
||||
export const AriaLabellingProvider = AriaLabellingContext.Provider;
|
||||
|
||||
/**
|
||||
* Reads the labelling context and returns `labelRef`/`descriptionRef` callbacks
|
||||
* for registering label and description elements. Must be called inside an
|
||||
* `AriaLabellingProvider`.
|
||||
*/
|
||||
export function useAriaLabellingContext(
|
||||
providerName: string
|
||||
): AriaLabellingContextType {
|
||||
|
||||
@@ -5,21 +5,24 @@ import { useCallback } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { tw } from '../tw.dom.tsx';
|
||||
|
||||
/** @internal */
|
||||
export namespace AxoBaseDialog {
|
||||
/**
|
||||
* AxoBaseDialog: Root
|
||||
* -------------------
|
||||
* <AxoBaseDialog.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type RootProps = Readonly<{
|
||||
/** Controlled open state. Must be used with `onOpenChange`. */
|
||||
open?: boolean;
|
||||
/** Called when the open state changes. */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* AxoBaseDialog: Trigger
|
||||
* ----------------------
|
||||
* <AxoBaseDialog.Trigger>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type TriggerProps = Readonly<{
|
||||
@@ -27,8 +30,8 @@ export namespace AxoBaseDialog {
|
||||
}>;
|
||||
|
||||
/**
|
||||
* AxoBaseDialog: Overlay
|
||||
* ----------------------
|
||||
* <AxoBaseDialog.Overlay>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const overlayStyles = tw(
|
||||
@@ -42,13 +45,13 @@ export namespace AxoBaseDialog {
|
||||
);
|
||||
|
||||
/**
|
||||
* AxoBaseDialog: Content
|
||||
* ----------------------
|
||||
* <AxoBaseDialog.Content>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const contentStyles = tw(
|
||||
'relative',
|
||||
'max-h-full min-h-fit max-w-full min-w-fit',
|
||||
'max-h-full min-h-fit',
|
||||
'curved-3xl bg-elevated-background-primary shadow-elevation-3 select-none',
|
||||
'text-label-primary',
|
||||
'not-forced-colors:outline-none not-forced-colors:keyboard-mode:focus:outline-focus-ring',
|
||||
@@ -57,8 +60,22 @@ export namespace AxoBaseDialog {
|
||||
'forced-colors:border forced-colors:border-[ButtonBorder] forced-colors:bg-[Canvas] forced-colors:text-[CanvasText]'
|
||||
);
|
||||
|
||||
/**
|
||||
* useContentEscapeBehavior()
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* How dangerous the cancel action is considered.
|
||||
* - `cancel-is-noop`: Canceling is safe — pressing Escape or clicking outside closes the dialog.
|
||||
* - `cancel-is-destructive`: Canceling would lose user state — pressing Escape or clicking outside is disabled.
|
||||
*/
|
||||
export type ContentEscape = 'cancel-is-noop' | 'cancel-is-destructive';
|
||||
|
||||
/**
|
||||
* Returns an escape-key handler for `onEscapeKeyDown`.
|
||||
* Prevents default when `escape` is `cancel-is-destructive`.
|
||||
*/
|
||||
export function useContentEscapeBehavior(
|
||||
escape: ContentEscape
|
||||
): (event: Event) => void {
|
||||
|
||||
@@ -10,7 +10,17 @@ import { isTestOrMockEnvironment } from '../../environment.std.ts';
|
||||
const LEGACY_CONTEXT_MENU_Z_INDEX = tw('legacy-z-index-context-menu');
|
||||
|
||||
export namespace AxoBaseMenu {
|
||||
/**
|
||||
* Horizontal alignment of the menu content relative to the trigger.
|
||||
* - `start`: Aligns the start edges.
|
||||
* - `center`: Centers the content over the trigger.
|
||||
* - `end`: Aligns the end edges.
|
||||
*/
|
||||
export type Align = 'center' | 'end' | 'start';
|
||||
|
||||
/**
|
||||
* Preferred side of the trigger to render the menu content against.
|
||||
*/
|
||||
export type Side = 'top' | 'right' | 'bottom' | 'left';
|
||||
|
||||
// <Content/SubContent>
|
||||
@@ -83,19 +93,31 @@ export namespace AxoBaseMenu {
|
||||
*/
|
||||
type BaseSelectableItemProps = BaseNavigableItemProps &
|
||||
Readonly<{
|
||||
/**
|
||||
* Keyboard shortcut hint displayed on the trailing side of the item
|
||||
* (e.g. `"⌘C"`).
|
||||
* Note: Decorative, does not register the shortcut.
|
||||
*/
|
||||
keyboardShortcut?: string;
|
||||
/**
|
||||
* Called when the item is selected via mouse or keyboard.
|
||||
* Call `event.preventDefault()` to keep the menu open after selection.
|
||||
*/
|
||||
onSelect?: (event: Event) => void;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* AxoBaseMenu: Item Slots
|
||||
* -----------------------
|
||||
* <AxoBaseMenu.ItemLeadingSlot>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ItemLeadingSlotProps = Readonly<{
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* First grid column of a menu item row. Holds the icon or check indicator.
|
||||
*/
|
||||
export function ItemLeadingSlot(props: ItemLeadingSlotProps): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
@@ -106,10 +128,18 @@ export namespace AxoBaseMenu {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* <AxoBaseMenu.ItemContentSlot>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ItemContentSlotProps = Readonly<{
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Second grid column of a menu item row. Holds the label and keyboard shortcut.
|
||||
*/
|
||||
export function ItemContentSlot(props: ItemContentSlotProps): JSX.Element {
|
||||
return (
|
||||
<span className={tw('col-start-2 col-end-2 flex min-w-0 items-center')}>
|
||||
@@ -119,8 +149,8 @@ export namespace AxoBaseMenu {
|
||||
}
|
||||
|
||||
/**
|
||||
* AxoBaseMenu: Item Parts
|
||||
* -----------------------
|
||||
* <AxoBaseMenu.ItemText>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const itemTextStyles = tw('flex-auto grow-0 truncate text-start');
|
||||
@@ -129,34 +159,73 @@ export namespace AxoBaseMenu {
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Truncated label text inside a menu item.
|
||||
*/
|
||||
export function ItemText(props: ItemTextProps): JSX.Element {
|
||||
return <span className={itemTextStyles}>{props.children}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* <AxoBaseMenu.ItemCheckPlaceholder>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ItemCheckPlaceholderProps = Readonly<{
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Fixed-width container for the check indicator. Reserves horizontal space
|
||||
* even when unchecked so item text stays aligned across all items.
|
||||
*/
|
||||
export function ItemCheckPlaceholder(
|
||||
props: ItemCheckPlaceholderProps
|
||||
): JSX.Element {
|
||||
return <span className={tw('w-3.5')}>{props.children}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* <AxoBaseMenu.ItemCheck>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* The checkmark icon rendered inside `ItemCheckPlaceholder` when
|
||||
* a checkbox or radio item is in a checked state.
|
||||
*/
|
||||
export function ItemCheck(): JSX.Element {
|
||||
return <AxoSymbol.Icon size={14} symbol="check" label={null} />;
|
||||
}
|
||||
|
||||
export function ItemSymbol(props: {
|
||||
/**
|
||||
* <AxoBaseMenu.ItemSymbol>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ItemSymbolProps = Readonly<{
|
||||
symbol: AxoSymbol.IconName;
|
||||
}): JSX.Element {
|
||||
}>;
|
||||
|
||||
/**
|
||||
* An icon rendered in the leading slot of a menu item.
|
||||
*/
|
||||
export function ItemSymbol(props: ItemSymbolProps): JSX.Element {
|
||||
return <AxoSymbol.Icon size={16} symbol={props.symbol} label={null} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* <AxoBaseMenu.ItemKeyboardShortcut>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ItemKeyboardShortcutProps = Readonly<{
|
||||
keyboardShortcut: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Right-aligned keyboard shortcut hint (e.g. `"⌘C"`) inside a menu item.
|
||||
*/
|
||||
export function ItemKeyboardShortcut(
|
||||
props: ItemKeyboardShortcutProps
|
||||
): JSX.Element {
|
||||
@@ -173,21 +242,27 @@ export namespace AxoBaseMenu {
|
||||
}
|
||||
|
||||
/**
|
||||
* AxoBaseMenu: Root
|
||||
* -----------------
|
||||
* <AxoBaseMenu.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type MenuRootProps = Readonly<{
|
||||
// Note: Radix context menus don't have an `open` prop
|
||||
// so we have to push it down to the dropdown menu props
|
||||
/**
|
||||
* Note: Radix context menus don't have an `open` prop
|
||||
* so we have to push it down to the dropdown menu props
|
||||
*/
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/**
|
||||
* When `true`, pointer events outside the menu are disabled while it's open.
|
||||
* Defaults to `true` for dropdown menus.
|
||||
*/
|
||||
modal?: boolean;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* AxoBaseMenu: Trigger
|
||||
* --------------------
|
||||
* <AxoBaseMenu.Trigger>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type MenuTriggerProps = Readonly<{
|
||||
@@ -200,13 +275,25 @@ export namespace AxoBaseMenu {
|
||||
}>;
|
||||
|
||||
/**
|
||||
* AxoBaseMenu: Content
|
||||
* --------------------
|
||||
* <AxoBaseMenu.Content>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type MenuContentProps = Readonly<{
|
||||
/**
|
||||
* Horizontal alignment of the content relative to the trigger.
|
||||
* Only applies to `AxoDropdownMenu`.
|
||||
*/
|
||||
align?: Align;
|
||||
/**
|
||||
* Preferred side of the trigger to render the content against.
|
||||
* Only applies to `AxoDropdownMenu`.
|
||||
*/
|
||||
side?: Side;
|
||||
/**
|
||||
* Called when focus would be restored after the menu closes.
|
||||
* Call `event.preventDefault()` to suppress auto-focus restoration.
|
||||
*/
|
||||
onCloseAutoFocus?: (e: Event) => void;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
@@ -222,16 +309,16 @@ export namespace AxoBaseMenu {
|
||||
export const selectContentViewportStyles = tw(baseContentGridStyles);
|
||||
|
||||
/**
|
||||
* AxoBaseMenu: Item
|
||||
* -----------------
|
||||
* <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.
|
||||
* keyboard). Calling `event.preventDefault()` in this handler will
|
||||
* prevent the context menu from closing when selecting that item.
|
||||
*/
|
||||
onSelect: (event: Event) => void;
|
||||
children: ReactNode;
|
||||
@@ -241,8 +328,8 @@ export namespace AxoBaseMenu {
|
||||
export const selectItemStyles = tw(selectableItemStyles);
|
||||
|
||||
/**
|
||||
* AxoBaseMenu: Group
|
||||
* ------------------
|
||||
* <AxoBaseMenu.Group>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type MenuGroupProps = Readonly<{
|
||||
@@ -253,8 +340,8 @@ export namespace AxoBaseMenu {
|
||||
export const selectGroupStyles = tw(baseGroupStyles);
|
||||
|
||||
/**
|
||||
* AxoBaseMenu: Label
|
||||
* ------------------
|
||||
* <AxoBaseMenu.Label>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type MenuLabelProps = Readonly<{
|
||||
@@ -270,7 +357,8 @@ export namespace AxoBaseMenu {
|
||||
export const selectLabelStyles = tw(baseLabelStyles);
|
||||
|
||||
/**
|
||||
* AxoBaseMenu: Header
|
||||
* <AxoBaseMenu.Header>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const menuHeaderStyles = tw('col-span-full col-start-1 p-1.5');
|
||||
@@ -282,8 +370,8 @@ export namespace AxoBaseMenu {
|
||||
);
|
||||
|
||||
/**
|
||||
* AxoBaseMenu: CheckboxItem
|
||||
* -------------------------
|
||||
* <AxoBaseMenu.CheckboxItem>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type MenuCheckboxItemProps = BaseSelectableItemProps &
|
||||
@@ -303,8 +391,8 @@ export namespace AxoBaseMenu {
|
||||
export const menuCheckboxItemStyles = tw(selectableItemStyles);
|
||||
|
||||
/**
|
||||
* AxoBaseMenu: RadioGroup
|
||||
* -----------------------
|
||||
* <AxoBaseMenu.RadioGroup>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type MenuRadioGroupProps = Readonly<{
|
||||
@@ -312,7 +400,6 @@ export namespace AxoBaseMenu {
|
||||
* The value of the selected item in the group.
|
||||
*/
|
||||
value: string | null;
|
||||
|
||||
/**
|
||||
* Event handler called when the value changes.
|
||||
*/
|
||||
@@ -323,12 +410,15 @@ export namespace AxoBaseMenu {
|
||||
export const menuRadioGroupStyles = tw(baseGroupStyles);
|
||||
|
||||
/**
|
||||
* AxoBaseMenu: RadioItem
|
||||
* ----------------------
|
||||
* <AxoBaseMenu.RadioItem>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type MenuRadioItemProps = BaseSelectableItemProps &
|
||||
Readonly<{
|
||||
/**
|
||||
* The value submitted when this item is selected.
|
||||
*/
|
||||
value: string;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
@@ -336,8 +426,8 @@ export namespace AxoBaseMenu {
|
||||
export const menuRadioItemStyles = tw(selectableItemStyles);
|
||||
|
||||
/**
|
||||
* AxoBaseMenu: Separator
|
||||
* ----------------------
|
||||
* <AxoBaseMenu.Separator>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const baseSeparatorStyles = tw('my-1 border-t-[0.5px] border-border-primary');
|
||||
@@ -353,8 +443,8 @@ export namespace AxoBaseMenu {
|
||||
export const selectSeperatorStyles = tw(baseItemStyles, baseSeparatorStyles);
|
||||
|
||||
/**
|
||||
* AxoBaseMenu: Sub
|
||||
* ----------------
|
||||
* <AxoBaseMenu.Sub>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type MenuSubProps = Readonly<{
|
||||
@@ -362,8 +452,8 @@ export namespace AxoBaseMenu {
|
||||
}>;
|
||||
|
||||
/**
|
||||
* AxoBaseMenu: SubTrigger
|
||||
* -----------------------
|
||||
* <AxoBaseMenu.SubTrigger>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type MenuSubTriggerProps = BaseNavigableItemProps &
|
||||
@@ -379,8 +469,8 @@ export namespace AxoBaseMenu {
|
||||
);
|
||||
|
||||
/**
|
||||
* AxoBaseMenu: SubContent
|
||||
* -----------------------
|
||||
* <AxoBaseMenu.SubContent>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type MenuSubContentProps = Readonly<{
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
CSSProperties,
|
||||
FC,
|
||||
ForwardedRef,
|
||||
HTMLAttributes,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { forwardRef, memo, useId, useMemo } from 'react';
|
||||
import type { CSSProperties, FC, Ref, ReactNode } from 'react';
|
||||
import { memo, useId, useMemo } from 'react';
|
||||
import type { Transition } from 'motion/react';
|
||||
import { motion } from 'motion/react';
|
||||
import type { TailwindStyles } from '../tw.dom.tsx';
|
||||
import { tw } from '../tw.dom.tsx';
|
||||
import { ExperimentalAxoBadge } from '../AxoBadge.dom.tsx';
|
||||
import { createStrictContext, useStrictContext } from './StrictContext.dom.tsx';
|
||||
|
||||
const Namespace = 'AxoBaseSegmentedControl';
|
||||
import { variants } from './variants.dom.tsx';
|
||||
|
||||
/**
|
||||
* Used to share styles/animations for SegmentedControls, Toolbar ToggleGroups,
|
||||
@@ -34,12 +25,37 @@ const Namespace = 'AxoBaseSegmentedControl';
|
||||
* ```
|
||||
*/
|
||||
export namespace ExperimentalAxoBaseSegmentedControl {
|
||||
/**
|
||||
* <AxoBaseSegmentedControl.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Visual style variant.
|
||||
*/
|
||||
export type Variant = 'track' | 'no-track';
|
||||
|
||||
/**
|
||||
* How the control sizes itself horizontally:
|
||||
* - `fit`: Shrinks to fit its content.
|
||||
* - `full`: Fills available width.
|
||||
*/
|
||||
export type RootWidth = 'fit' | 'full';
|
||||
|
||||
/**
|
||||
* How each item sizes itself within the control:
|
||||
* - `fit`: Items size to their content.
|
||||
* - `equal`: All items share equal width.
|
||||
*/
|
||||
export type ItemWidth = 'fit' | 'equal';
|
||||
|
||||
/**
|
||||
* The currently selected value(s).
|
||||
* A string for single-select, an array for multi-select, or `null` for nothing selected.
|
||||
*/
|
||||
export type RootValue = string | ReadonlyArray<string> | null;
|
||||
|
||||
/** @internal */
|
||||
type RootContextType = Readonly<{
|
||||
id: string;
|
||||
value: RootValue;
|
||||
@@ -48,40 +64,87 @@ export namespace ExperimentalAxoBaseSegmentedControl {
|
||||
itemWidth: ItemWidth;
|
||||
}>;
|
||||
|
||||
const RootContext = createStrictContext<RootContextType>(`${Namespace}.Root`);
|
||||
/** @internal */
|
||||
const RootContext = createStrictContext<RootContextType>(
|
||||
`AxoBaseSegmentedControl.Root`
|
||||
);
|
||||
|
||||
type VariantConfig = {
|
||||
rootStyles: TailwindStyles;
|
||||
indicatorStyles: TailwindStyles;
|
||||
};
|
||||
const baseRootStyles = tw(
|
||||
'flex min-w-min flex-row items-center justify-items-stretch',
|
||||
'rounded-full',
|
||||
'forced-colors:border',
|
||||
'forced-colors:border-[ButtonBorder]'
|
||||
);
|
||||
|
||||
const base: VariantConfig = {
|
||||
rootStyles: tw(
|
||||
'flex min-w-min flex-row items-center justify-items-stretch',
|
||||
'rounded-full',
|
||||
'forced-colors:border',
|
||||
'forced-colors:border-[ButtonBorder]'
|
||||
),
|
||||
indicatorStyles: tw(
|
||||
'pointer-events-none absolute inset-0 z-10 rounded-full',
|
||||
'forced-colors:bg-[SelectedItem]'
|
||||
),
|
||||
};
|
||||
const RootStyles = variants<Variant>(`AxoBaseSegmentedControl.Variant`, {
|
||||
track: tw(baseRootStyles, 'bg-fill-secondary'),
|
||||
'no-track': baseRootStyles,
|
||||
});
|
||||
|
||||
const Variants: Record<Variant, VariantConfig> = {
|
||||
track: {
|
||||
rootStyles: tw(base.rootStyles, 'bg-fill-secondary'),
|
||||
indicatorStyles: tw(
|
||||
base.indicatorStyles,
|
||||
'bg-fill-primary',
|
||||
'shadow-elevation-1'
|
||||
),
|
||||
},
|
||||
'no-track': {
|
||||
rootStyles: tw(base.rootStyles),
|
||||
indicatorStyles: tw(base.indicatorStyles, 'bg-fill-selected'),
|
||||
},
|
||||
};
|
||||
const baseIndicatorStyles = tw(
|
||||
'pointer-events-none absolute inset-0 z-10 rounded-full',
|
||||
'forced-colors:bg-[SelectedItem]'
|
||||
);
|
||||
|
||||
const IndicatorStyles = variants<Variant>(`AxoBaseSegmentedControl.Variant`, {
|
||||
track: tw(baseIndicatorStyles, 'bg-fill-primary', 'shadow-elevation-1'),
|
||||
'no-track': tw(baseIndicatorStyles, 'bg-fill-selected'),
|
||||
});
|
||||
|
||||
/**
|
||||
* <AxoBaseSegmentedControl.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const RootWidths = variants<RootWidth>(`AxoBaseSegmentedControl.RootWidth`, {
|
||||
fit: tw('w-fit'),
|
||||
full: tw('w-full'),
|
||||
});
|
||||
|
||||
export type RootProps = Readonly<{
|
||||
/** Ref to the underlying `<div>` element. */
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
/** The currently selected value(s). */
|
||||
value: RootValue;
|
||||
/** Visual style variant. */
|
||||
variant: Variant;
|
||||
/** How the control sizes itself horizontally. */
|
||||
width: RootWidth;
|
||||
/** How each item sizes itself within the control. */
|
||||
itemWidth: ItemWidth;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
const { value, variant, width, itemWidth, children, ...rest } = props;
|
||||
const id = useId();
|
||||
const context = useMemo(() => {
|
||||
return { id, value, variant, rootWidth: width, itemWidth };
|
||||
}, [id, value, variant, width, itemWidth]);
|
||||
return (
|
||||
<RootContext.Provider value={context}>
|
||||
<div
|
||||
ref={props.ref}
|
||||
className={tw(RootStyles.get(variant), RootWidths.get(width))}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</RootContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = 'AxoBaseSegmentedControl.Root';
|
||||
|
||||
/**
|
||||
* <AxoBaseSegmentedControl.Item>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const ItemWidths = variants<ItemWidth>(`AxoBaseSegmentedControl.ItemWidth`, {
|
||||
fit: tw('min-w-0 shrink grow basis-auto'),
|
||||
equal: tw('flex-1'),
|
||||
});
|
||||
|
||||
const IndicatorTransition: Transition = {
|
||||
type: 'spring',
|
||||
@@ -90,130 +153,80 @@ export namespace ExperimentalAxoBaseSegmentedControl {
|
||||
mass: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Component: <AxoBaseSegmentedControl.Root>
|
||||
* -----------------------------------------
|
||||
*/
|
||||
export type ItemProps = Readonly<{
|
||||
/** Ref to the underlying `<button>` element. */
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
/** The value this item represents. */
|
||||
value: string;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
const RootWidths: Record<RootWidth, TailwindStyles> = {
|
||||
fit: tw('w-fit'),
|
||||
full: tw('w-full'),
|
||||
};
|
||||
export const Item: FC<ItemProps> = memo(props => {
|
||||
const { value, children, ...rest } = props;
|
||||
const context = useStrictContext(RootContext);
|
||||
|
||||
export type RootProps = HTMLAttributes<HTMLDivElement> &
|
||||
Readonly<{
|
||||
value: RootValue;
|
||||
variant: Variant;
|
||||
width: RootWidth;
|
||||
itemWidth: ItemWidth;
|
||||
}>;
|
||||
const isSelected = useMemo(() => {
|
||||
if (context.value == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export const Root: FC<RootProps> = memo(
|
||||
forwardRef((props, ref: ForwardedRef<HTMLDivElement>) => {
|
||||
const { value, variant, width, itemWidth, ...rest } = props;
|
||||
const id = useId();
|
||||
const config = Variants[variant];
|
||||
const widthStyles = RootWidths[width];
|
||||
const context = useMemo(() => {
|
||||
return { id, value, variant, rootWidth: width, itemWidth };
|
||||
}, [id, value, variant, width, itemWidth]);
|
||||
return (
|
||||
<RootContext.Provider value={context}>
|
||||
<div
|
||||
ref={ref}
|
||||
{...rest}
|
||||
className={tw(config.rootStyles, widthStyles)}
|
||||
if (Array.isArray(context.value)) {
|
||||
return context.value.includes(value);
|
||||
}
|
||||
|
||||
return context.value === value;
|
||||
}, [value, context.value]);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={props.ref}
|
||||
type="button"
|
||||
className={tw(
|
||||
'relative flex min-w-0 items-center justify-center px-3 py-[5px]',
|
||||
'cursor-pointer rounded-full type-body-medium font-medium text-label-primary',
|
||||
'outline-border-focused not-forced-colors:outline-none not-forced-colors:keyboard-mode:focus:outline-focus-ring',
|
||||
'forced-colors:bg-[ButtonFace] forced-colors:text-[ButtonText]',
|
||||
'forced-colors:data-[axo-contextmenu-state=open]:text-[HighlightText]',
|
||||
ItemWidths.get(context.itemWidth),
|
||||
isSelected && tw('forced-colors:text-[SelectedItemText]'),
|
||||
!isSelected &&
|
||||
tw(
|
||||
'data-[axo-contextmenu-state=open]:bg-fill-secondary',
|
||||
'forced-colors:data-[axo-contextmenu-state=open]:bg-[Highlight]'
|
||||
)
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
{isSelected && (
|
||||
<motion.span
|
||||
layoutId={`${context.id}.Indicator`}
|
||||
className={IndicatorStyles.get(context.variant)}
|
||||
transition={IndicatorTransition}
|
||||
style={{ borderRadius: 14 }}
|
||||
/>
|
||||
</RootContext.Provider>
|
||||
);
|
||||
})
|
||||
);
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = `${Namespace}.Root`;
|
||||
Item.displayName = 'AxoBaseSegmentedControl.Item';
|
||||
|
||||
/**
|
||||
* Component: <AxoBaseSegmentedControl.Item>
|
||||
* -----------------------------------------
|
||||
*/
|
||||
|
||||
const ItemWidths: Record<ItemWidth, TailwindStyles> = {
|
||||
fit: tw('min-w-0 shrink grow basis-auto'),
|
||||
equal: tw('flex-1'),
|
||||
};
|
||||
|
||||
export type ItemProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
Readonly<{
|
||||
value: string;
|
||||
}>;
|
||||
|
||||
export const Item: FC<ItemProps> = memo(
|
||||
forwardRef((props, ref: ForwardedRef<HTMLButtonElement>) => {
|
||||
const { value, ...rest } = props;
|
||||
|
||||
const context = useStrictContext(RootContext);
|
||||
const config = Variants[context.variant];
|
||||
const itemWidthStyles = ItemWidths[context.itemWidth];
|
||||
|
||||
const isSelected = useMemo(() => {
|
||||
if (context.value == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(context.value)) {
|
||||
return context.value.includes(value);
|
||||
}
|
||||
|
||||
return context.value === value;
|
||||
}, [value, context.value]);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
{...rest}
|
||||
className={tw(
|
||||
'relative flex min-w-0 items-center justify-center px-3 py-[5px]',
|
||||
'cursor-pointer rounded-full type-body-medium font-medium text-label-primary',
|
||||
'outline-border-focused not-forced-colors:outline-none not-forced-colors:keyboard-mode:focus:outline-focus-ring',
|
||||
'forced-colors:bg-[ButtonFace] forced-colors:text-[ButtonText]',
|
||||
'forced-colors:data-[axo-contextmenu-state=open]:text-[HighlightText]',
|
||||
itemWidthStyles,
|
||||
isSelected && tw('forced-colors:text-[SelectedItemText]'),
|
||||
!isSelected &&
|
||||
tw(
|
||||
'data-[axo-contextmenu-state=open]:bg-fill-secondary',
|
||||
'forced-colors:data-[axo-contextmenu-state=open]:bg-[Highlight]'
|
||||
)
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
{isSelected && (
|
||||
<motion.span
|
||||
layoutId={`${context.id}.Indicator`}
|
||||
className={config.indicatorStyles}
|
||||
transition={IndicatorTransition}
|
||||
style={{ borderRadius: 14 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
Item.displayName = `${Namespace}.Item`;
|
||||
|
||||
/**
|
||||
* Component: <AxoBaseSegmentedControl.ItemText>
|
||||
* ---------------------------------------------
|
||||
* <AxoBaseSegmentedControl.ItemText>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/** CSS `max-width` value for the item label, used to prevent overflow. */
|
||||
export type ItemMaxWidth = CSSProperties['maxWidth'];
|
||||
|
||||
export type ItemTextProps = Readonly<{
|
||||
/** Maximum width for the label before it truncates. */
|
||||
maxWidth?: ItemMaxWidth;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/** Truncated label text inside a segmented control item. */
|
||||
export const ItemText: FC<ItemTextProps> = memo(props => {
|
||||
return (
|
||||
<span
|
||||
@@ -225,11 +238,11 @@ export namespace ExperimentalAxoBaseSegmentedControl {
|
||||
);
|
||||
});
|
||||
|
||||
ItemText.displayName = `${Namespace}.ItemText`;
|
||||
ItemText.displayName = 'AxoBaseSegmentedControl.ItemText';
|
||||
|
||||
/**
|
||||
* Component: <AxoBaseSegmentedControl.ItemBadge>
|
||||
* ----------------------------------------------
|
||||
* <AxoBaseSegmentedControl.ItemBadge>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ExperimentalItemBadgeProps = Omit<
|
||||
@@ -237,6 +250,7 @@ export namespace ExperimentalAxoBaseSegmentedControl {
|
||||
'size'
|
||||
>;
|
||||
|
||||
/** A badge rendered to the right of the item label. */
|
||||
export const ExperimentalItemBadge = memo(
|
||||
(props: ExperimentalItemBadgeProps) => {
|
||||
return (
|
||||
@@ -246,12 +260,12 @@ export namespace ExperimentalAxoBaseSegmentedControl {
|
||||
value={props.value}
|
||||
max={props.max}
|
||||
maxDisplay={props.maxDisplay}
|
||||
aria-label={props['aria-label']}
|
||||
label={props.label}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ExperimentalItemBadge.displayName = `${Namespace}.ItemBadge`;
|
||||
ExperimentalItemBadge.displayName = 'AxoBaseSegmentedControl.ItemBadge';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright 2026 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { memo, useState } from 'react';
|
||||
import { createStrictContext, useStrictContext } from './StrictContext.dom.tsx';
|
||||
|
||||
const IntlContext =
|
||||
createStrictContext<AxoIntl.ContextType>('AxoIntl.Provider');
|
||||
|
||||
/** @internal Localization context for built-in Axo UI strings. */
|
||||
export namespace AxoIntl {
|
||||
const DefaultMessages = {
|
||||
'AxoButton.Pending': 'Pending',
|
||||
'AxoDialog.Back': 'Back',
|
||||
'AxoDialog.Close': 'Close',
|
||||
'AxoTextField.Clear': 'Clear',
|
||||
};
|
||||
|
||||
/** A key for a built-in Axo UI string. */
|
||||
export type MessageKey = keyof typeof DefaultMessages;
|
||||
|
||||
/** Map of all message keys to their translated strings. */
|
||||
export type Messages = Record<MessageKey, string>;
|
||||
|
||||
/** The intl API available via `useAxoIntl`. */
|
||||
export type ContextType = Readonly<{
|
||||
get: (key: MessageKey) => string;
|
||||
}>;
|
||||
|
||||
function createIntlContext(messages: Messages): ContextType {
|
||||
return {
|
||||
get(key) {
|
||||
return messages[key] ?? DefaultMessages[key];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* <AxoIntl.Provider>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ProviderProps = Readonly<{
|
||||
/** Translated strings for all Axo message keys. */
|
||||
messages: Messages;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/** Provides translated strings to all Axo components in the tree. */
|
||||
export const Provider: FC<ProviderProps> = memo(props => {
|
||||
const { messages } = props;
|
||||
const [intl] = useState(() => {
|
||||
return createIntlContext(messages);
|
||||
});
|
||||
return (
|
||||
<IntlContext.Provider value={intl}>{props.children}</IntlContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
Provider.displayName = 'AxoIntl.Provider';
|
||||
}
|
||||
|
||||
/**
|
||||
* useAxoIntl()
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/** Returns the intl context for reading translated Axo UI strings. */
|
||||
export function useAxoIntl(): AxoIntl.ContextType {
|
||||
return useStrictContext(IntlContext);
|
||||
}
|
||||
@@ -7,6 +7,12 @@ export type FlexWrapDetectorProps = Readonly<{
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Detects when flex items wrap and exposes `container-scrollable` /
|
||||
* `container-not-scrollable` container-query states to descendants.
|
||||
* Used internally by `AxoAlertDialog.Footer` to toggle between stacked
|
||||
* (full-width) and inline (equal-basis) button layouts.
|
||||
*/
|
||||
export const FlexWrapDetector = memo(function FlexWrapDetector(
|
||||
props: FlexWrapDetectorProps
|
||||
) {
|
||||
|
||||
@@ -7,16 +7,31 @@ import { createContext, useContext } from 'react';
|
||||
const EMPTY: unique symbol = Symbol('STRICT_CONTEXT_EMPTY');
|
||||
const WRAPPER: unique symbol = Symbol('STRICT_CONTEXT_MESSAGE');
|
||||
|
||||
/**
|
||||
* A React context that throws if consumed outside its provider,
|
||||
* rather than silently returning `undefined`.
|
||||
*/
|
||||
export type StrictContext<T> = Context<T | typeof EMPTY> & {
|
||||
[WRAPPER]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Similar to `createContext()` but does not accept a nullable value in
|
||||
* `Provider`.
|
||||
*
|
||||
* Use with `useStrictContext()` to assert that the component is wrapped with a
|
||||
* provider.
|
||||
*/
|
||||
export function createStrictContext<T>(wrapper: string): StrictContext<T> {
|
||||
return Object.assign(createContext<T | typeof EMPTY>(EMPTY), {
|
||||
[WRAPPER]: wrapper,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to `useContext()` but throws a descriptive error if the component is
|
||||
* not wrapped with a provider.
|
||||
*/
|
||||
export function useStrictContext<T>(
|
||||
context: StrictContext<T>,
|
||||
message?: string
|
||||
|
||||
@@ -21,7 +21,13 @@ const AbstractRoles = {
|
||||
window: true,
|
||||
} as const satisfies Record<string, true>;
|
||||
|
||||
/** An abstract WAI-ARIA role (e.g. `widget`, `landmark`). Cannot be assigned to elements directly. */
|
||||
export type AbstractAriaRole = keyof typeof AbstractRoles;
|
||||
|
||||
/**
|
||||
* A concrete WAI-ARIA role assignable to an element, including roles
|
||||
* missing from React's built-in `AriaRole` type.
|
||||
*/
|
||||
export type AriaRole =
|
||||
| Exclude<ReactAriaRole, object>
|
||||
// Missing from React's types
|
||||
@@ -152,7 +158,7 @@ const ParentRoles = {
|
||||
dialog: ['window'],
|
||||
} as const satisfies Record<AnyAriaRole, ReadonlyArray<AnyAriaRole>>;
|
||||
|
||||
function inherits(role: AnyAriaRole, superRole: AnyAriaRole): boolean {
|
||||
function _inherits(role: AnyAriaRole, superRole: AnyAriaRole): boolean {
|
||||
if (role === superRole) {
|
||||
return true;
|
||||
}
|
||||
@@ -160,7 +166,7 @@ function inherits(role: AnyAriaRole, superRole: AnyAriaRole): boolean {
|
||||
const parentRoles = assert(ParentRoles[role], `Unknown Aria role: ${role}`);
|
||||
|
||||
for (const parentRole of parentRoles) {
|
||||
if (inherits(parentRole, superRole)) {
|
||||
if (_inherits(parentRole, superRole)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -172,6 +178,7 @@ function isValidAriaRole(role: string | null): role is AriaRole {
|
||||
return role != null && Object.hasOwn(ParentRoles, role);
|
||||
}
|
||||
|
||||
/** Returns the computed WAI-ARIA role of a DOM element, or `null` if the role is not a valid concrete role. */
|
||||
export function getElementAriaRole(element: Element): AriaRole | null {
|
||||
const role = getRole(element);
|
||||
if (isValidAriaRole(role)) {
|
||||
@@ -180,6 +187,7 @@ export function getElementAriaRole(element: Element): AriaRole | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Returns `true` if `role` inherits from the `widget` abstract role (i.e. it is interactive). */
|
||||
export function isAriaWidgetRole(role: AriaRole | null): boolean {
|
||||
return role != null && inherits(role, 'widget');
|
||||
return role != null && _inherits(role, 'widget');
|
||||
}
|
||||
|
||||
@@ -1,11 +1,82 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/**
|
||||
* An error class representing an assertion that failed.
|
||||
* @example
|
||||
* ```ts
|
||||
* function example(value: number) {
|
||||
* assert(Number.isSafeInteger(value))
|
||||
* // >> AssertionError: Value is not an integer
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
class AssertionError extends TypeError {
|
||||
override name = 'AssertionError';
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a condition is true.
|
||||
* @example
|
||||
* ```ts
|
||||
* function example(value: number) {
|
||||
* assert(Number.isSafeInteger(value)) // throws if not safe integer
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Can alternatively be used to assert a value is not null or undefined,
|
||||
* returning the non-nullable value.
|
||||
* @example
|
||||
* ```ts
|
||||
* function example(value: string | null | undefined) {
|
||||
* let result = assert(value) // asserts is not null or undefined
|
||||
* result.toUpperCase() // string
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Optionally provide a message:
|
||||
* @example
|
||||
* ```ts
|
||||
* function example(value: number) {
|
||||
* assert(Number.isSafeInteger(value), "Value is not an integer")
|
||||
* // >> AssertionError: Value is not an integer
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function assert(condition: boolean, message?: string): asserts condition;
|
||||
|
||||
/**
|
||||
* Assert a value is not null or undefined, returning the non-nullable value.
|
||||
* @example
|
||||
* ```ts
|
||||
* function example(value: string | null | undefined) {
|
||||
* let result = assert(value) // asserts is not null or undefined
|
||||
* result.toUpperCase() // string
|
||||
* }
|
||||
*
|
||||
* ```
|
||||
*
|
||||
* Can alternatively be used to assert a condition is true.
|
||||
* @example
|
||||
* ```ts
|
||||
* function example(value: number) {
|
||||
* assert(Number.isSafeInteger(value)) // throws if not safe integer
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Optionally provide a message:
|
||||
* @example
|
||||
* ```ts
|
||||
* function example(value: string | null | undefined) {
|
||||
* let result = assert(value, "Missing value")
|
||||
* // >> AssertionError: Missing value
|
||||
* result.toUpperCase() // string
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
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) {
|
||||
@@ -17,6 +88,24 @@ export function assert<T>(input: T, message?: string): NonNullable<T> {
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a state should never be reached.
|
||||
*
|
||||
* Can be used to make an exhaustive check on a value.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function example(value: 'one' | 'two') {
|
||||
* if (value === 'one') {
|
||||
* // ...
|
||||
* } else if (value === 'two') {
|
||||
* // ...
|
||||
* } else {
|
||||
* unreachable(value);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function unreachable(_value: never): never {
|
||||
// oxlint-disable-next-line no-debugger
|
||||
debugger;
|
||||
|
||||
@@ -153,6 +153,16 @@ function applyGlobalProperties(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts hidden scrollers into the DOM to measure system scrollbar gutter widths.
|
||||
*
|
||||
* Provides values as custom CSS properties on `<html>`:
|
||||
*
|
||||
* - `--axo-scrollbar-gutter-auto-vertical`
|
||||
* - `--axo-scrollbar-gutter-auto-horizontal`
|
||||
* - `--axo-scrollbar-gutter-thin-vertical`
|
||||
* - `--axo-scrollbar-gutter-thin-horizontal`
|
||||
*/
|
||||
export function createScrollbarGutterCssProperties(): Unsubscribe {
|
||||
const autoObserver = new ScrollbarGuttersObserver('auto');
|
||||
const thinObserver = new ScrollbarGuttersObserver('thin');
|
||||
|
||||
@@ -6,7 +6,10 @@ export namespace utf8 {
|
||||
const HIGH_SURROGATE = 0b11011000_00000000;
|
||||
const LOW_SURROGATE = 0b11011100_00000000;
|
||||
|
||||
/** Source: https://codeberg.org/indutny/protopiler/src/branch/main/lib/utf8.mjs */
|
||||
/**
|
||||
* Source: https://codeberg.org/indutny/protopiler/src/branch/main/lib/utf8.mjs
|
||||
* @internal
|
||||
*/
|
||||
function _getByteLength(
|
||||
input: string,
|
||||
onlyNeedToKnowIsOver: number | null
|
||||
@@ -53,6 +56,7 @@ export namespace utf8 {
|
||||
return size;
|
||||
}
|
||||
|
||||
/** Returns the UTF-8 byte length of `input`. */
|
||||
export function getByteLength(input: string): number {
|
||||
return _getByteLength(input, null);
|
||||
}
|
||||
@@ -60,36 +64,45 @@ export namespace utf8 {
|
||||
const MIN_BYTES_PER_CHAR = 1;
|
||||
const MAX_BYTES_PER_CHAR = 3;
|
||||
|
||||
/** @internal */
|
||||
function _minPossibleBytes(input: string) {
|
||||
return input.length * MIN_BYTES_PER_CHAR;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
function _maxPossibleBytes(input: string) {
|
||||
return input.length * MAX_BYTES_PER_CHAR;
|
||||
}
|
||||
|
||||
/** Returns `true` if the UTF-8 byte length of `input` is less than `n`. */
|
||||
export function lt(input: string, n: number): boolean {
|
||||
return _maxPossibleBytes(input) < n || _getByteLength(input, n) < n;
|
||||
}
|
||||
|
||||
/** Returns `true` if the UTF-8 byte length of `input` is less than or equal to `n`. */
|
||||
export function lte(input: string, n: number): boolean {
|
||||
return _maxPossibleBytes(input) <= n || _getByteLength(input, n) <= n;
|
||||
}
|
||||
|
||||
/** Returns `true` if the UTF-8 byte length of `input` is greater than `n`. */
|
||||
export function gt(input: string, n: number): boolean {
|
||||
return _minPossibleBytes(input) > n || _getByteLength(input, n) > n;
|
||||
}
|
||||
|
||||
/** Returns `true` if the UTF-8 byte length of `input` is greater than or equal to `n`. */
|
||||
export function gte(input: string, n: number): boolean {
|
||||
return _minPossibleBytes(input) >= n || _getByteLength(input, n) >= n;
|
||||
}
|
||||
|
||||
let segmenter: Intl.Segmenter;
|
||||
function getSegmenter() {
|
||||
|
||||
/** @internal */
|
||||
function _getSegmenter() {
|
||||
segmenter ??= new Intl.Segmenter('und', { granularity: 'grapheme' });
|
||||
return segmenter;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
function _getGraphemeCount(
|
||||
input: string,
|
||||
onlyNeedToKnowIsOver: number | null
|
||||
@@ -100,7 +113,7 @@ export namespace utf8 {
|
||||
|
||||
let result = 0;
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
for (const _grapheme of getSegmenter().segment(input)) {
|
||||
for (const _grapheme of _getSegmenter().segment(input)) {
|
||||
result += 1;
|
||||
|
||||
if (onlyNeedToKnowIsOver != null && result > onlyNeedToKnowIsOver) {
|
||||
@@ -111,10 +124,12 @@ export namespace utf8 {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Returns the number of user-perceived grapheme clusters in `input`. */
|
||||
export function getGraphemeCount(input: string): number {
|
||||
return _getGraphemeCount(input, null);
|
||||
}
|
||||
|
||||
/** Returns `input` truncated to at most `maxBytes` grapheme clusters. */
|
||||
export function truncateGraphemes(input: string, maxBytes: number): string {
|
||||
if (maxBytes === 0) {
|
||||
return '';
|
||||
@@ -123,7 +138,7 @@ export namespace utf8 {
|
||||
let count = 0;
|
||||
let result = '';
|
||||
|
||||
for (const item of getSegmenter().segment(input)) {
|
||||
for (const item of _getSegmenter().segment(input)) {
|
||||
count += 1;
|
||||
result += item.segment;
|
||||
|
||||
@@ -135,6 +150,10 @@ export namespace utf8 {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `input` truncated to fit within both `maxBytes` UTF-8 bytes and
|
||||
* `maxGraphemes` grapheme clusters, whichever limit is reached first.
|
||||
*/
|
||||
export function truncateBytesAndGraphemes(
|
||||
value: string,
|
||||
maxBytes: number,
|
||||
@@ -148,7 +167,7 @@ export namespace utf8 {
|
||||
let remainingBytes = maxBytes;
|
||||
let remainingGraphemes = maxGraphemes;
|
||||
|
||||
for (const grapheme of getSegmenter().segment(value)) {
|
||||
for (const grapheme of _getSegmenter().segment(value)) {
|
||||
remainingBytes -= _getByteLength(grapheme.segment, remainingBytes);
|
||||
remainingGraphemes -= 1;
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright 2026 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// Must be namespace import
|
||||
// See: https://react.dev/reference/react/captureOwnerStack#captureownerstack-is-not-available
|
||||
import * as React from 'react';
|
||||
import type { TailwindStyles } from '../tw.dom.tsx';
|
||||
import { isTestOrMockEnvironment } from '../../environment.std.ts';
|
||||
|
||||
export type Variants<
|
||||
Key extends string | number,
|
||||
Value = TailwindStyles,
|
||||
> = Readonly<{
|
||||
get: (key: Key) => Value;
|
||||
keys: () => ReadonlyArray<`${Key}`>;
|
||||
}>;
|
||||
|
||||
export function variants<Key extends string | number, Value = TailwindStyles>(
|
||||
typeName: string,
|
||||
values: Record<Key, Value>
|
||||
): Variants<Key, Value> {
|
||||
return {
|
||||
get(key) {
|
||||
if (!Object.hasOwn(values, key) && isTestOrMockEnvironment()) {
|
||||
// Will not exist in production builds
|
||||
// See: https://react.dev/reference/react/captureOwnerStack#captureownerstack-is-not-available
|
||||
const ownerStack = React.captureOwnerStack?.() ?? '';
|
||||
throw new Error(`Unexpected ${typeName}: "${key}"${ownerStack}`);
|
||||
}
|
||||
return values[key];
|
||||
},
|
||||
keys() {
|
||||
return Object.keys(values) as Array<`${Key}`>;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,21 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/** Opaque type for styles returned by tw() */
|
||||
export type TailwindStyles = string & { __Styles: never };
|
||||
|
||||
/**
|
||||
* Joins Tailwind CSS class names, filtering out falsy values.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* tw('flex items-center gap-2')
|
||||
* // => 'flex items-center gap-2'
|
||||
*
|
||||
* tw('my-3', isActive && 'bg-blue-500', isDisabled && 'opacity-50')
|
||||
* // => 'my-3 bg-blue-500' (when isActive=true, isDisabled=false)
|
||||
* ```
|
||||
*/
|
||||
export function tw(
|
||||
...classNames: ReadonlyArray<
|
||||
TailwindStyles | string | boolean | null | undefined
|
||||
|
||||
@@ -123,11 +123,7 @@ export function CallQualitySurveyDialog(
|
||||
<AxoDialog.Title>
|
||||
{i18n('icu:CallQualitySurvey__HowWasYourCall__PageTitle')}
|
||||
</AxoDialog.Title>
|
||||
<AxoDialog.Close
|
||||
aria-label={i18n(
|
||||
'icu:CallQualitySurvey__CloseButton__AccessibilityLabel'
|
||||
)}
|
||||
/>
|
||||
<AxoDialog.Close />
|
||||
</AxoDialog.Header>
|
||||
<AxoDialog.Body>
|
||||
<p className={tw('mb-3 type-body-medium text-label-primary')}>
|
||||
@@ -173,9 +169,6 @@ export function CallQualitySurveyDialog(
|
||||
<>
|
||||
<AxoDialog.Header>
|
||||
<AxoDialog.Back
|
||||
aria-label={i18n(
|
||||
'icu:CallQualitySurvey__BackButton__AccessibilityLabel'
|
||||
)}
|
||||
onClick={() => {
|
||||
setPage(Page.HOW_WAS_YOUR_CALL);
|
||||
}}
|
||||
@@ -183,11 +176,7 @@ export function CallQualitySurveyDialog(
|
||||
<AxoDialog.Title>
|
||||
{i18n('icu:CallQualitySurvey__WhatIssuesDidYouHave__PageTitle')}
|
||||
</AxoDialog.Title>
|
||||
<AxoDialog.Close
|
||||
aria-label={i18n(
|
||||
'icu:CallQualitySurvey__CloseButton__AccessibilityLabel'
|
||||
)}
|
||||
/>
|
||||
<AxoDialog.Close />
|
||||
</AxoDialog.Header>
|
||||
<AxoDialog.Body>
|
||||
<p className={tw('mb-3 type-body-medium text-label-primary')}>
|
||||
@@ -303,9 +292,6 @@ export function CallQualitySurveyDialog(
|
||||
<>
|
||||
<AxoDialog.Header>
|
||||
<AxoDialog.Back
|
||||
aria-label={i18n(
|
||||
'icu:CallQualitySurvey__BackButton__AccessibilityLabel'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!userSatisfied) {
|
||||
setPage(Page.WHAT_ISSUES_DID_YOU_HAVE);
|
||||
@@ -317,11 +303,7 @@ export function CallQualitySurveyDialog(
|
||||
<AxoDialog.Title>
|
||||
{i18n('icu:CallQualitySurvey__ConfirmSubmission__PageTitle')}
|
||||
</AxoDialog.Title>
|
||||
<AxoDialog.Close
|
||||
aria-label={i18n(
|
||||
'icu:CallQualitySurvey__CloseButton__AccessibilityLabel'
|
||||
)}
|
||||
/>
|
||||
<AxoDialog.Close />
|
||||
</AxoDialog.Header>
|
||||
<AxoDialog.Body>
|
||||
<p className={tw('mb-3 type-body-medium text-label-primary')}>
|
||||
@@ -367,15 +349,7 @@ export function CallQualitySurveyDialog(
|
||||
<AxoDialog.Action
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
experimentalSpinner={
|
||||
isSubmitting
|
||||
? {
|
||||
'aria-label': i18n(
|
||||
'icu:CallQualitySurvey__ConfirmSubmission__Submitting'
|
||||
),
|
||||
}
|
||||
: null
|
||||
}
|
||||
pending={isSubmitting}
|
||||
>
|
||||
{i18n(
|
||||
'icu:CallQualitySurvey__ConfirmSubmission__SubmitButton'
|
||||
@@ -615,7 +589,7 @@ function IssueToggle(props: {
|
||||
variant={props.isSelected ? 'primary' : 'secondary'}
|
||||
size="md"
|
||||
symbol={isSelected ? 'check' : ISSUE_ICONS[issue]}
|
||||
aria-pressed={props.isSelected}
|
||||
pressed={props.isSelected}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{getIssueLabel(i18n, issue)}
|
||||
|
||||
@@ -26,9 +26,7 @@ export function DeleteAttachmentConfirmationDialog({
|
||||
<AxoDialog.Title>
|
||||
{i18n('icu:DeleteAttachmentModal__Title')}
|
||||
</AxoDialog.Title>
|
||||
<AxoDialog.Close
|
||||
aria-label={i18n('icu:DeleteAttachmentModal__Close')}
|
||||
/>
|
||||
<AxoDialog.Close />
|
||||
</AxoDialog.Header>
|
||||
<AxoDialog.Body>
|
||||
{i18n('icu:DeleteAttachmentModal__Body')}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function DonationPrivacyInformationModal({
|
||||
<AxoDialog.Title screenReaderOnly>
|
||||
{i18n('icu:PreferencesDonations__privacy-modal-title')}
|
||||
</AxoDialog.Title>
|
||||
<AxoDialog.Close aria-label={i18n('icu:PinMessageDialog__Close')} />
|
||||
<AxoDialog.Close />
|
||||
</AxoDialog.Header>
|
||||
<AxoDialog.Body>
|
||||
<img
|
||||
|
||||
@@ -71,15 +71,7 @@ export function KeyTransparencyErrorDialog(
|
||||
<AxoDialog.Action
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
experimentalSpinner={
|
||||
isSubmitting
|
||||
? {
|
||||
'aria-label': i18n(
|
||||
'icu:KeyTransparencyErrorDialog__Submitting'
|
||||
),
|
||||
}
|
||||
: null
|
||||
}
|
||||
pending={isSubmitting}
|
||||
>
|
||||
{i18n('icu:KeyTransparencyErrorDialog__Submit')}
|
||||
</AxoDialog.Action>
|
||||
|
||||
@@ -28,11 +28,7 @@ export function KeyTransparencyOnboardingDialog(
|
||||
<AxoDialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<AxoDialog.Content escape="cancel-is-noop" size="sm">
|
||||
<AxoDialog.Header>
|
||||
<AxoDialog.Close
|
||||
aria-label={i18n(
|
||||
'icu:KeyTransparencyOnboardingDialog__CloseButton__AccessibilityLabel'
|
||||
)}
|
||||
/>
|
||||
<AxoDialog.Close />
|
||||
</AxoDialog.Header>
|
||||
<AxoDialog.Body>
|
||||
<div className={tw('mt-1.5 mb-3 flex items-center justify-center')}>
|
||||
|
||||
@@ -1437,14 +1437,10 @@ export function MediaEditor({
|
||||
/>
|
||||
</div>
|
||||
<AxoButton.Root
|
||||
disabled={!image || isSaving || isSending}
|
||||
variant="primary"
|
||||
size="md"
|
||||
experimentalSpinner={
|
||||
isSending
|
||||
? { 'aria-label': doneButtonLabel || i18n('icu:save') }
|
||||
: null
|
||||
}
|
||||
disabled={!image}
|
||||
pending={isSaving || isSending}
|
||||
onClick={async () => {
|
||||
if (!fabricCanvas) {
|
||||
return;
|
||||
|
||||
@@ -106,15 +106,7 @@ export function PlaintextExportWorkflow({
|
||||
</AxoDialog.Action>
|
||||
<AxoDialog.Action
|
||||
variant="primary"
|
||||
experimentalSpinner={
|
||||
shouldShowSpinner
|
||||
? {
|
||||
'aria-label': i18n(
|
||||
'icu:PlaintextExport--Confirmation--WaitingLabel'
|
||||
),
|
||||
}
|
||||
: null
|
||||
}
|
||||
pending={shouldShowSpinner}
|
||||
onClick={() => verifyWithOSForExport(includeMedia)}
|
||||
>
|
||||
{i18n('icu:PlaintextExport--Confirmation--ContinueButton')}
|
||||
|
||||
@@ -1359,10 +1359,7 @@ export function Preferences({
|
||||
<AxoButton.Root
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={nowSyncing}
|
||||
experimentalSpinner={
|
||||
nowSyncing ? { 'aria-label': i18n('icu:syncing') } : null
|
||||
}
|
||||
pending={nowSyncing}
|
||||
onClick={async () => {
|
||||
setShowSyncFailed(false);
|
||||
setNowSyncing(true);
|
||||
|
||||
@@ -348,12 +348,7 @@ export function PreferencesInternal({
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={validateBackup}
|
||||
disabled={isValidationPending}
|
||||
experimentalSpinner={
|
||||
isValidationPending
|
||||
? { 'aria-label': i18n('icu:loading') }
|
||||
: null
|
||||
}
|
||||
pending={isValidationPending}
|
||||
>
|
||||
{i18n('icu:Preferences__internal__validate-backup')}
|
||||
</AxoButton.Root>
|
||||
@@ -526,12 +521,7 @@ export function PreferencesInternal({
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={() => handleGenerateReceipt(receipt)}
|
||||
disabled={isGeneratingReceipt}
|
||||
experimentalSpinner={
|
||||
isGeneratingReceipt
|
||||
? { 'aria-label': i18n('icu:loading') }
|
||||
: null
|
||||
}
|
||||
pending={isGeneratingReceipt}
|
||||
>
|
||||
Download
|
||||
</AxoButton.Root>
|
||||
@@ -762,11 +752,7 @@ export function PreferencesInternal({
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={handleKeyTransparencyCheck}
|
||||
experimentalSpinner={
|
||||
isKeyTransparencyRunning
|
||||
? { 'aria-label': i18n('icu:loading') }
|
||||
: null
|
||||
}
|
||||
pending={isKeyTransparencyRunning}
|
||||
>
|
||||
Check
|
||||
</AxoButton.Root>
|
||||
|
||||
@@ -266,10 +266,7 @@ export function PreferencesLocalBackups({
|
||||
<AxoButton.Root
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={isAuthPending}
|
||||
experimentalSpinner={
|
||||
isAuthPending ? { 'aria-label': i18n('icu:loading') } : null
|
||||
}
|
||||
pending={isAuthPending}
|
||||
onClick={async () => {
|
||||
if (
|
||||
!previouslyViewedBackupKeyHash ||
|
||||
@@ -514,9 +511,7 @@ function DisableLocalBackupsDialog({
|
||||
</AxoDialog.Action>
|
||||
<AxoDialog.Action
|
||||
variant="destructive"
|
||||
experimentalSpinner={
|
||||
isPending ? { 'aria-label': i18n('icu:loading') } : null
|
||||
}
|
||||
pending={isPending}
|
||||
onClick={handleDisableLocalBackups}
|
||||
>
|
||||
{i18n('icu:Preferences__local-backups-turn-off-action')}
|
||||
|
||||
@@ -1241,7 +1241,7 @@ function NotificationProfilesEditPage({
|
||||
/>
|
||||
<AriaClickable.HiddenTrigger
|
||||
onClick={onEditName}
|
||||
aria-labelledby="edit-icon"
|
||||
labelledby="edit-icon"
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -1255,22 +1255,12 @@ function NotificationProfilesEditPage({
|
||||
onUpdateOverrideState(value);
|
||||
}}
|
||||
>
|
||||
<AxoSelect.Trigger placeholder={currentActiveString}>
|
||||
{currentActiveString}
|
||||
</AxoSelect.Trigger>
|
||||
<AxoSelect.Trigger placeholder="" />
|
||||
<AxoSelect.Content>
|
||||
<AxoSelect.Item
|
||||
key="isActive"
|
||||
value={activeString}
|
||||
textValue={activeString}
|
||||
>
|
||||
<AxoSelect.Item value={activeString}>
|
||||
<AxoSelect.ItemText>{activeString}</AxoSelect.ItemText>
|
||||
</AxoSelect.Item>
|
||||
<AxoSelect.Item
|
||||
key="isNotActive"
|
||||
value={notActiveString}
|
||||
textValue={notActiveString}
|
||||
>
|
||||
<AxoSelect.Item value={notActiveString}>
|
||||
<AxoSelect.ItemText>{notActiveString}</AxoSelect.ItemText>
|
||||
</AxoSelect.Item>
|
||||
</AxoSelect.Content>
|
||||
|
||||
@@ -377,7 +377,7 @@ export function ProfileEditor({
|
||||
maxBytes={128}
|
||||
autoFocus
|
||||
showCount
|
||||
showClearWithLabel={i18n('icu:clear')}
|
||||
showClear
|
||||
/>
|
||||
</AxoTextField.Root>
|
||||
|
||||
@@ -394,7 +394,7 @@ export function ProfileEditor({
|
||||
maxGraphemes={26}
|
||||
maxBytes={128}
|
||||
showCount
|
||||
showClearWithLabel={i18n('icu:clear')}
|
||||
showClear
|
||||
/>
|
||||
</AxoTextField.Root>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,7 @@ export function SafetyNumberModal({
|
||||
<AxoDialog.Content size="sm" escape="cancel-is-noop">
|
||||
<AxoDialog.Header>
|
||||
<AxoDialog.Title>{title}</AxoDialog.Title>
|
||||
{hasXButton && <AxoDialog.Close aria-label={i18n('icu:close')} />}
|
||||
{hasXButton && <AxoDialog.Close />}
|
||||
</AxoDialog.Header>
|
||||
<AxoDialog.Body maxHeight={560}>{content}</AxoDialog.Body>
|
||||
</AxoDialog.Content>
|
||||
|
||||
@@ -81,7 +81,7 @@ function SafetyTipsSummary({
|
||||
<AxoDialog.Title>
|
||||
{i18n('icu:SafetyTipsModal__Title-v2')}
|
||||
</AxoDialog.Title>
|
||||
<AxoDialog.Close aria-label={i18n('icu:close')} />
|
||||
<AxoDialog.Close />
|
||||
</AxoDialog.Header>
|
||||
<AxoDialog.Body>
|
||||
<div className={tw('py-4')}>
|
||||
@@ -199,7 +199,7 @@ function SafetyTipsDetails({ i18n }: { i18n: LocalizerType }): JSX.Element {
|
||||
{i18n('icu:SafetyTipsModal__Title-v2')}
|
||||
</AxoDialog.Title>
|
||||
</div>
|
||||
<AxoDialog.Close aria-label={i18n('icu:close')} />
|
||||
<AxoDialog.Close />
|
||||
</AxoDialog.Header>
|
||||
<Tabs.Root
|
||||
value={tips[pageIndex]?.key}
|
||||
|
||||
@@ -397,9 +397,7 @@ export function UsernameEditor({
|
||||
size="lg"
|
||||
disabled={!canSave}
|
||||
onClick={onSave}
|
||||
experimentalSpinner={
|
||||
isConfirming ? { 'aria-label': i18n('icu:loading') } : null
|
||||
}
|
||||
pending={isConfirming}
|
||||
>
|
||||
{i18n('icu:save')}
|
||||
</AxoButton.Root>
|
||||
|
||||
@@ -405,8 +405,8 @@ function CollapseSetButton(
|
||||
variant="secondary"
|
||||
arrow={arrow}
|
||||
symbol={symbol}
|
||||
aria-expanded={ariaExpanded}
|
||||
aria-controls={disclosureContentId}
|
||||
expanded={ariaExpanded}
|
||||
controls={disclosureContentId}
|
||||
onClick={onClick}
|
||||
>
|
||||
{text}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function ProfileNameWarningModal({
|
||||
disableMissingAriaDescriptionWarning
|
||||
>
|
||||
<AxoDialog.Header>
|
||||
<AxoDialog.Close aria-label={i18n('icu:close')} />
|
||||
<AxoDialog.Close />
|
||||
</AxoDialog.Header>
|
||||
<AxoDialog.Body padding="normal">
|
||||
<div className={tw('flex justify-center')}>
|
||||
|
||||
@@ -112,11 +112,6 @@ export function GroupMemberLabelEditor({
|
||||
(labelStringForSave || undefined) !== (existingLabelString || undefined);
|
||||
const canSave =
|
||||
isDirty && ((!labelEmoji && !labelStringForSave) || labelStringForSave);
|
||||
const spinner = isSaving
|
||||
? {
|
||||
'aria-label': i18n('icu:ConversationDetails--member-label--saving'),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const contactLabelForMessage = labelStringForSave
|
||||
? { labelEmoji, labelString: labelStringForSave }
|
||||
@@ -373,8 +368,8 @@ export function GroupMemberLabelEditor({
|
||||
<AxoButton.Root
|
||||
variant="primary"
|
||||
size="md"
|
||||
experimentalSpinner={spinner}
|
||||
disabled={!canSave || isSaving}
|
||||
pending={isSaving}
|
||||
disabled={!canSave}
|
||||
onClick={() => {
|
||||
setIsSaving(true);
|
||||
updateGroupMemberLabel(
|
||||
|
||||
@@ -54,10 +54,7 @@ export function PanelRow({
|
||||
className={classNames(bem('root', 'button'), className)}
|
||||
>
|
||||
{!disabled && (
|
||||
<AriaClickable.HiddenTrigger
|
||||
onClick={onClick}
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
<AriaClickable.HiddenTrigger onClick={onClick} labelledby={labelId} />
|
||||
)}
|
||||
<div className={bem('inner')}>
|
||||
{content}
|
||||
|
||||
@@ -150,7 +150,7 @@ export function ListItem({
|
||||
</div>
|
||||
{renderContextMenu(
|
||||
mediaItem,
|
||||
<AriaClickable.HiddenTrigger aria-label={label} onClick={handleClick} />
|
||||
<AriaClickable.HiddenTrigger label={label} onClick={handleClick} />
|
||||
)}
|
||||
<AriaClickable.SubWidget>
|
||||
<button
|
||||
|
||||
@@ -233,7 +233,7 @@ function PinMessageSelectDurationDialog(props: {
|
||||
<AxoDialog.Title>
|
||||
{i18n('icu:PinMessageDialog__Title')}
|
||||
</AxoDialog.Title>
|
||||
<AxoDialog.Close aria-label={i18n('icu:PinMessageDialog__Close')} />
|
||||
<AxoDialog.Close />
|
||||
</AxoDialog.Header>
|
||||
<AxoDialog.Body>
|
||||
<AxoRadioGroup.Root
|
||||
|
||||
@@ -271,7 +271,7 @@ function HiddenTrigger(props: {
|
||||
|
||||
return (
|
||||
<AriaClickable.HiddenTrigger
|
||||
aria-label={i18n(
|
||||
label={i18n(
|
||||
'icu:PinnedMessagesBar__GoToMessageClickableArea__AccessibilityLabel'
|
||||
)}
|
||||
onClick={handlePinGoToCurrent}
|
||||
|
||||
@@ -219,7 +219,7 @@ function ChatFolderSelectItem(props: {
|
||||
maxCount: UNREAD_BADGE_MAX_COUNT,
|
||||
}
|
||||
)}
|
||||
aria-label={null}
|
||||
label={null}
|
||||
/>
|
||||
)}
|
||||
</AxoSelect.Item>
|
||||
@@ -263,7 +263,7 @@ function ChatFolderSegmentedControlItem(props: {
|
||||
'icu:LeftPaneChatFolders__ItemUnreadBadge__MaxCount',
|
||||
{ maxCount: UNREAD_BADGE_MAX_COUNT }
|
||||
)}
|
||||
aria-label={null}
|
||||
label={null}
|
||||
/>
|
||||
)}
|
||||
</ExperimentalAxoSegmentedControl.Item>
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { StrictMode } from 'react';
|
||||
import EmbedBlot from '@signalapp/quill-cjs/blots/embed.js';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { Emojify } from '../../components/conversation/Emojify.dom.tsx';
|
||||
import { normalizeAci } from '../../util/normalizeAci.std.ts';
|
||||
import type { MentionBlotValue } from '../util.dom.ts';
|
||||
import { AxoProvider } from '../../axo/AxoProvider.dom.tsx';
|
||||
|
||||
const { i18n } = window.SignalContext;
|
||||
import { AppProvider } from '../../windows/AppProvider.dom.tsx';
|
||||
|
||||
export class MentionBlot extends EmbedBlot {
|
||||
static override blotName = 'mention';
|
||||
@@ -49,16 +46,14 @@ export class MentionBlot extends EmbedBlot {
|
||||
const mentionSpan = document.createElement('span');
|
||||
|
||||
createRoot(mentionSpan).render(
|
||||
<StrictMode>
|
||||
<AxoProvider dir={i18n.getLocaleDirection()}>
|
||||
<span className="module-composition-input__at-mention">
|
||||
<bdi>
|
||||
@
|
||||
<Emojify text={mention.title} />
|
||||
</bdi>
|
||||
</span>
|
||||
</AxoProvider>
|
||||
</StrictMode>
|
||||
<AppProvider>
|
||||
<span className="module-composition-input__at-mention">
|
||||
<bdi>
|
||||
@
|
||||
<Emojify text={mention.title} />
|
||||
</bdi>
|
||||
</span>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
node.appendChild(mentionSpan);
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { ClearingData } from '../components/ClearingData.dom.tsx';
|
||||
import { strictAssert } from '../util/assert.std.ts';
|
||||
import { deleteAllData } from './deleteAllData.preload.ts';
|
||||
import { AxoProvider } from '../axo/AxoProvider.dom.tsx';
|
||||
import { AppProvider } from '../windows/AppProvider.dom.tsx';
|
||||
|
||||
export function renderClearingDataView(): void {
|
||||
const appContainer = document.getElementById('app-container');
|
||||
|
||||
strictAssert(appContainer != null, 'No #app-container');
|
||||
createRoot(appContainer).render(
|
||||
<StrictMode>
|
||||
<AxoProvider dir={window.SignalContext.i18n.getLocaleDirection()}>
|
||||
<ClearingData
|
||||
deleteAllData={deleteAllData}
|
||||
i18n={window.SignalContext.i18n}
|
||||
/>
|
||||
</AxoProvider>
|
||||
</StrictMode>
|
||||
<AppProvider>
|
||||
<ClearingData
|
||||
deleteAllData={deleteAllData}
|
||||
i18n={window.SignalContext.i18n}
|
||||
/>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,14 +8,14 @@ import type { Store } from 'redux';
|
||||
|
||||
import { SmartApp } from '../smart/App.preload.tsx';
|
||||
import { SmartVoiceNotesPlaybackProvider } from '../smart/VoiceNotesPlaybackProvider.preload.tsx';
|
||||
import { AxoProvider } from '../../axo/AxoProvider.dom.tsx';
|
||||
import { AppProvider } from '../../windows/AppProvider.dom.tsx';
|
||||
|
||||
export const createApp = (store: Store): ReactElement => (
|
||||
<AxoProvider dir={window.SignalContext.i18n.getLocaleDirection()}>
|
||||
<AppProvider>
|
||||
<Provider store={store}>
|
||||
<SmartVoiceNotesPlaybackProvider>
|
||||
<SmartApp />
|
||||
</SmartVoiceNotesPlaybackProvider>
|
||||
</Provider>
|
||||
</AxoProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
@@ -4,14 +4,12 @@
|
||||
// TODO DESKTOP-4761
|
||||
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import type { Store } from 'redux';
|
||||
|
||||
import { ModalHost } from '../../components/ModalHost.dom.tsx';
|
||||
import type { SmartGroupV2JoinDialogProps } from '../smart/GroupV2JoinDialog.dom.tsx';
|
||||
import { SmartGroupV2JoinDialog } from '../smart/GroupV2JoinDialog.dom.tsx';
|
||||
import { AppProvider } from '../../windows/AppProvider.dom.tsx';
|
||||
|
||||
export const createGroupV2JoinModal = (
|
||||
store: Store,
|
||||
@@ -20,10 +18,12 @@ export const createGroupV2JoinModal = (
|
||||
const { onClose } = props;
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<ModalHost modalName="createGroupV2JoinModal" onClose={onClose}>
|
||||
<SmartGroupV2JoinDialog {...props} />
|
||||
</ModalHost>
|
||||
</Provider>
|
||||
<AppProvider>
|
||||
<Provider store={store}>
|
||||
<ModalHost modalName="createGroupV2JoinModal" onClose={onClose}>
|
||||
<SmartGroupV2JoinDialog {...props} />
|
||||
</ModalHost>
|
||||
</Provider>
|
||||
</AppProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { StrictMode, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import type { AudioDevice } from '@signalapp/ringrtc';
|
||||
@@ -91,7 +91,6 @@ import { useBackupActions } from '../ducks/backups.preload.ts';
|
||||
import { isFeaturedEnabledSelector } from '../../util/isFeatureEnabled.dom.ts';
|
||||
import { SmartPreferencesChatFoldersPage } from './PreferencesChatFoldersPage.preload.tsx';
|
||||
import { SmartPreferencesEditChatFolderPage } from './PreferencesEditChatFolderPage.preload.tsx';
|
||||
import { AxoProvider } from '../../axo/AxoProvider.dom.tsx';
|
||||
import {
|
||||
getCurrentChatFoldersCount,
|
||||
getHasAnyCurrentCustomChatFolders,
|
||||
@@ -122,6 +121,7 @@ import type { ZoomFactorType } from '../../types/StorageKeys.std.ts';
|
||||
import { isLocalBackupsEnabled } from '../../util/isLocalBackupsEnabled.preload.ts';
|
||||
import { getBackupKeyHash } from '../../services/backups/crypto.preload.ts';
|
||||
import { Emoji } from '../../axo/emoji.std.ts';
|
||||
import { AppProvider } from '../../windows/AppProvider.dom.tsx';
|
||||
|
||||
const DEFAULT_NOTIFICATION_SETTING = 'message';
|
||||
|
||||
@@ -895,231 +895,229 @@ export function SmartPreferences(): JSX.Element | null {
|
||||
};
|
||||
|
||||
return (
|
||||
<StrictMode>
|
||||
<AxoProvider dir={i18n.getLocaleDirection()}>
|
||||
<Preferences
|
||||
backupKey={backupKey}
|
||||
backupKeyHash={backupKeyHash}
|
||||
addCustomColor={addCustomColor}
|
||||
autoDownloadAttachment={autoDownloadAttachment}
|
||||
availableCameras={availableCameras}
|
||||
availableLocales={availableLocales}
|
||||
availableMicrophones={availableMicrophones}
|
||||
availableSpeakers={availableSpeakers}
|
||||
backupTier={backupLevelFromNumber(backupTier)}
|
||||
backupSubscriptionStatus={
|
||||
backupSubscriptionStatus ?? { status: 'not-found' }
|
||||
}
|
||||
backupFreeMediaDays={backupFreeMediaDays}
|
||||
backupMediaDownloadStatus={{
|
||||
completedBytes: backupMediaDownloadCompletedBytes ?? 0,
|
||||
totalBytes: backupMediaDownloadTotalBytes ?? 0,
|
||||
isPaused: Boolean(backupMediaDownloadPaused),
|
||||
isIdle: Boolean(attachmentDownloadManagerIdled),
|
||||
}}
|
||||
backupLocalBackupsEnabled={backupLocalBackupsEnabled}
|
||||
badge={badge}
|
||||
blockedContacts={blockedContacts}
|
||||
blockedGroups={blockedGroups}
|
||||
currentChatFoldersCount={currentChatFoldersCount}
|
||||
cloudBackupStatus={cloudBackupStatus}
|
||||
customColors={customColors}
|
||||
defaultConversationColor={defaultConversationColor}
|
||||
deviceName={deviceName}
|
||||
disableLocalBackups={backupsService.disableLocalBackups}
|
||||
emojiSkinToneDefault={emojiSkinToneDefault}
|
||||
phoneNumber={phoneNumber}
|
||||
doDeleteAllData={doDeleteAllData}
|
||||
editCustomColor={editCustomColor}
|
||||
getConversationsWithCustomColor={getConversationsWithCustomColor}
|
||||
getMessageCountBySchemaVersion={
|
||||
DataReader.getMessageCountBySchemaVersion
|
||||
}
|
||||
getMessageSampleForSchemaVersion={
|
||||
DataReader.getMessageSampleForSchemaVersion
|
||||
}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
hasAnyCurrentCustomChatFolders={hasAnyCurrentCustomChatFolders}
|
||||
hasAudioNotifications={hasAudioNotifications}
|
||||
hasAutoConvertEmoji={hasAutoConvertEmoji}
|
||||
hasAutoDownloadUpdate={hasAutoDownloadUpdate}
|
||||
hasAutoLaunch={hasAutoLaunch}
|
||||
hasKeepMutedChatsArchived={hasKeepMutedChatsArchived}
|
||||
hasCallNotifications={hasCallNotifications}
|
||||
hasCallRingtoneNotification={hasCallRingtoneNotification}
|
||||
hasContentProtection={hasContentProtection}
|
||||
hasCountMutedConversations={hasCountMutedConversations}
|
||||
hasFailedStorySends={hasFailedStorySends}
|
||||
hasHideMenuBar={hasHideMenuBar}
|
||||
hasIncomingCallNotifications={hasIncomingCallNotifications}
|
||||
hasKeyTransparencyDisabled={hasKeyTransparencyDisabled}
|
||||
hasLinkPreviews={hasLinkPreviews}
|
||||
hasMediaCameraPermissions={hasMediaCameraPermissions}
|
||||
hasMediaPermissions={hasMediaPermissions}
|
||||
hasMessageAudio={hasMessageAudio}
|
||||
hasMinimizeToAndStartInSystemTray={hasMinimizeToAndStartInSystemTray}
|
||||
hasMinimizeToSystemTray={hasMinimizeToSystemTray}
|
||||
hasNotificationAttention={hasNotificationAttention}
|
||||
hasNotifications={hasNotifications}
|
||||
hasPreferContactAvatars={hasPreferContactAvatars}
|
||||
hasReadReceipts={hasReadReceipts}
|
||||
hasRelayCalls={hasRelayCalls}
|
||||
hasSealedSenderIndicators={hasSealedSenderIndicators}
|
||||
hasSpellCheck={hasSpellCheck}
|
||||
hasStoriesDisabled={hasStoriesDisabled}
|
||||
hasTextFormatting={hasTextFormatting}
|
||||
hasTypingIndicators={hasTypingIndicators}
|
||||
i18n={i18n}
|
||||
initialSpellCheckSetting={initialSpellCheckSetting}
|
||||
isAutoDownloadUpdatesSupported={isAutoDownloadUpdatesSupported}
|
||||
isAutoLaunchSupported={isAutoLaunchSupported}
|
||||
isContentProtectionNeeded={isContentProtectionNeeded}
|
||||
isContentProtectionSupported={isContentProtectionSupported}
|
||||
isHideMenuBarSupported={isHideMenuBarSupported}
|
||||
isKeyTransparencyAvailable={isKeyTransparencyAvailable}
|
||||
isMinimizeToAndStartInSystemTraySupported={
|
||||
isMinimizeToAndStartInSystemTraySupported
|
||||
}
|
||||
isNotificationAttentionSupported={isNotificationAttentionSupported}
|
||||
isPlaintextExportEnabled={isPlaintextExportEnabled}
|
||||
isSyncSupported={isSyncSupported}
|
||||
isSystemTraySupported={isSystemTraySupported}
|
||||
isInternalUser={isInternalUser}
|
||||
lastLocalBackup={lastLocalBackup}
|
||||
lastSyncTime={lastSyncTime}
|
||||
localBackupFolder={localBackupFolder}
|
||||
localeOverride={localeOverride}
|
||||
makeSyncRequest={makeSyncRequest}
|
||||
me={me}
|
||||
navTabsCollapsed={navTabsCollapsed}
|
||||
notificationContent={notificationContent}
|
||||
notificationProfileCount={notificationProfileCount}
|
||||
onAudioNotificationsChange={onAudioNotificationsChange}
|
||||
onAutoConvertEmojiChange={onAutoConvertEmojiChange}
|
||||
onAutoDownloadAttachmentChange={onAutoDownloadAttachmentChange}
|
||||
onAutoDownloadUpdateChange={onAutoDownloadUpdateChange}
|
||||
onAutoLaunchChange={onAutoLaunchChange}
|
||||
onBackupKeyViewed={onBackupKeyViewed}
|
||||
onCallNotificationsChange={onCallNotificationsChange}
|
||||
onCallRingtoneNotificationChange={onCallRingtoneNotificationChange}
|
||||
onContentProtectionChange={onContentProtectionChange}
|
||||
onCountMutedConversationsChange={onCountMutedConversationsChange}
|
||||
onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange}
|
||||
onHasKeyTransparencyDisabledChanged={
|
||||
onHasKeyTransparencyDisabledChanged
|
||||
}
|
||||
onHasStoriesDisabledChanged={onHasStoriesDisabledChanged}
|
||||
onHideMenuBarChange={onHideMenuBarChange}
|
||||
onIncomingCallNotificationsChange={onIncomingCallNotificationsChange}
|
||||
onKeepMutedChatsArchivedChange={onKeepMutedChatsArchivedChange}
|
||||
onLastSyncTimeChange={onLastSyncTimeChange}
|
||||
onLinkPreviewsChange={onLinkPreviewsChange}
|
||||
onLocaleChange={onLocaleChange}
|
||||
onMediaCameraPermissionsChange={onMediaCameraPermissionsChange}
|
||||
onMediaPermissionsChange={onMediaPermissionsChange}
|
||||
onMessageAudioChange={onMessageAudioChange}
|
||||
onMinimizeToAndStartInSystemTrayChange={
|
||||
onMinimizeToAndStartInSystemTrayChange
|
||||
}
|
||||
onMinimizeToSystemTrayChange={onMinimizeToSystemTrayChange}
|
||||
onNotificationAttentionChange={onNotificationAttentionChange}
|
||||
onNotificationContentChange={onNotificationContentChange}
|
||||
onNotificationsChange={onNotificationsChange}
|
||||
onStartUpdate={startUpdate}
|
||||
onPreferContactAvatarsChange={onPreferContactAvatarsChange}
|
||||
onReadReceiptsChange={onReadReceiptsChange}
|
||||
onRelayCallsChange={onRelayCallsChange}
|
||||
onSealedSenderIndicatorsChange={onSealedSenderIndicatorsChange}
|
||||
onSelectedCameraChange={onSelectedCameraChange}
|
||||
onSelectedMicrophoneChange={onSelectedMicrophoneChange}
|
||||
onSelectedSpeakerChange={onSelectedSpeakerChange}
|
||||
onSentMediaQualityChange={onSentMediaQualityChange}
|
||||
onSpellCheckChange={onSpellCheckChange}
|
||||
onTextFormattingChange={onTextFormattingChange}
|
||||
onThemeChange={onThemeChange}
|
||||
onToggleNavTabsCollapse={toggleNavTabsCollapse}
|
||||
onTypingIndicatorsChange={onTypingIndicatorsChange}
|
||||
onUniversalExpireTimerChange={onUniversalExpireTimerChange}
|
||||
onWhoCanFindMeChange={onWhoCanFindMeChange}
|
||||
onWhoCanSeeMeChange={onWhoCanSeeMeChange}
|
||||
onZoomFactorChange={onZoomFactorChange}
|
||||
openFileInFolder={openFileInFolder}
|
||||
osName={osName}
|
||||
otherTabsUnreadStats={otherTabsUnreadStats}
|
||||
settingsLocation={settingsLocation}
|
||||
pickLocalBackupFolder={pickLocalBackupFolder}
|
||||
preferredSystemLocales={preferredSystemLocales}
|
||||
preferredWidthFromStorage={preferredWidthFromStorage}
|
||||
refreshCloudBackupStatus={refreshCloudBackupStatus}
|
||||
refreshBackupSubscriptionStatus={refreshBackupSubscriptionStatus}
|
||||
removeCustomColorOnConversations={removeCustomColorOnConversations}
|
||||
removeCustomColor={removeCustomColor}
|
||||
renderDonationsPane={renderDonationsPane}
|
||||
renderNotificationProfilesHome={renderNotificationProfilesHome}
|
||||
renderNotificationProfilesCreateFlow={
|
||||
renderNotificationProfilesCreateFlow
|
||||
}
|
||||
renderProfileEditor={renderProfileEditor}
|
||||
renderToastManager={renderToastManagerWithoutMegaphone}
|
||||
renderUpdateDialog={renderUpdateDialog}
|
||||
renderPreferencesChatFoldersPage={renderPreferencesChatFoldersPage}
|
||||
renderPreferencesEditChatFolderPage={
|
||||
renderPreferencesEditChatFolderPage
|
||||
}
|
||||
previouslyViewedBackupKeyHash={previouslyViewedBackupKeyHash}
|
||||
promptOSAuth={promptOSAuth}
|
||||
resetAllChatColors={resetAllChatColors}
|
||||
resetDefaultChatColor={resetDefaultChatColor}
|
||||
resolvedLocale={resolvedLocale}
|
||||
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
|
||||
resumeBackupMediaDownload={resumeBackupMediaDownload}
|
||||
pauseBackupMediaDownload={pauseBackupMediaDownload}
|
||||
cancelBackupMediaDownload={cancelBackupMediaDownload}
|
||||
selectedCamera={selectedCamera}
|
||||
selectedMicrophone={selectedMicrophone}
|
||||
selectedSpeaker={selectedSpeaker}
|
||||
sentMediaQualitySetting={sentMediaQualitySetting}
|
||||
setGlobalDefaultConversationColor={setGlobalDefaultConversationColor}
|
||||
setSettingsLocation={setSettingsLocation}
|
||||
shouldShowUpdateDialog={shouldShowUpdateDialog}
|
||||
showToast={showToast}
|
||||
startLocalBackupExport={startLocalBackupExport}
|
||||
startPlaintextExport={startPlaintextExport}
|
||||
theme={theme}
|
||||
themeSetting={themeSetting}
|
||||
universalExpireTimer={universalExpireTimer}
|
||||
validateBackup={validateBackup}
|
||||
whoCanFindMe={whoCanFindMe}
|
||||
whoCanSeeMe={whoCanSeeMe}
|
||||
zoomFactor={zoomFactor}
|
||||
donationReceipts={donationReceipts}
|
||||
internalAddDonationReceipt={internalAddDonationReceipt}
|
||||
saveAttachmentToDisk={saveAttachmentToDisk}
|
||||
generateDonationReceiptBlob={generateDonationReceiptBlob}
|
||||
addVisibleMegaphone={addVisibleMegaphone}
|
||||
internalDeleteAllMegaphones={internalDeleteAllMegaphones}
|
||||
__dangerouslyRunAbitraryReadOnlySqlQuery={
|
||||
__dangerouslyRunAbitraryReadOnlySqlQuery
|
||||
}
|
||||
cqsTestMode={cqsTestMode}
|
||||
setCqsTestMode={setCqsTestMode}
|
||||
dredDuration={items.dredDuration}
|
||||
setDredDuration={setDredDuration}
|
||||
setIsDirectVp9Enabled={setIsDirectVp9Enabled}
|
||||
isDirectVp9Enabled={items.isDirectVp9Enabled}
|
||||
setDirectMaxBitrate={setDirectMaxBitrate}
|
||||
directMaxBitrate={items.directMaxBitrate}
|
||||
setIsGroupVp9Enabled={setIsGroupVp9Enabled}
|
||||
isGroupVp9Enabled={items.isGroupVp9Enabled}
|
||||
setGroupMaxBitrate={setGroupMaxBitrate}
|
||||
groupMaxBitrate={items.groupMaxBitrate}
|
||||
sfuUrl={items.sfuUrl}
|
||||
setSfuUrl={setSfuUrl}
|
||||
forceKeyTransparencyCheck={forceKeyTransparencyCheck}
|
||||
keyTransparencySelfHealth={items.keyTransparencySelfHealth}
|
||||
weArePrimaryDevice={weArePrimaryDevice}
|
||||
/>
|
||||
</AxoProvider>
|
||||
</StrictMode>
|
||||
<AppProvider>
|
||||
<Preferences
|
||||
backupKey={backupKey}
|
||||
backupKeyHash={backupKeyHash}
|
||||
addCustomColor={addCustomColor}
|
||||
autoDownloadAttachment={autoDownloadAttachment}
|
||||
availableCameras={availableCameras}
|
||||
availableLocales={availableLocales}
|
||||
availableMicrophones={availableMicrophones}
|
||||
availableSpeakers={availableSpeakers}
|
||||
backupTier={backupLevelFromNumber(backupTier)}
|
||||
backupSubscriptionStatus={
|
||||
backupSubscriptionStatus ?? { status: 'not-found' }
|
||||
}
|
||||
backupFreeMediaDays={backupFreeMediaDays}
|
||||
backupMediaDownloadStatus={{
|
||||
completedBytes: backupMediaDownloadCompletedBytes ?? 0,
|
||||
totalBytes: backupMediaDownloadTotalBytes ?? 0,
|
||||
isPaused: Boolean(backupMediaDownloadPaused),
|
||||
isIdle: Boolean(attachmentDownloadManagerIdled),
|
||||
}}
|
||||
backupLocalBackupsEnabled={backupLocalBackupsEnabled}
|
||||
badge={badge}
|
||||
blockedContacts={blockedContacts}
|
||||
blockedGroups={blockedGroups}
|
||||
currentChatFoldersCount={currentChatFoldersCount}
|
||||
cloudBackupStatus={cloudBackupStatus}
|
||||
customColors={customColors}
|
||||
defaultConversationColor={defaultConversationColor}
|
||||
deviceName={deviceName}
|
||||
disableLocalBackups={backupsService.disableLocalBackups}
|
||||
emojiSkinToneDefault={emojiSkinToneDefault}
|
||||
phoneNumber={phoneNumber}
|
||||
doDeleteAllData={doDeleteAllData}
|
||||
editCustomColor={editCustomColor}
|
||||
getConversationsWithCustomColor={getConversationsWithCustomColor}
|
||||
getMessageCountBySchemaVersion={
|
||||
DataReader.getMessageCountBySchemaVersion
|
||||
}
|
||||
getMessageSampleForSchemaVersion={
|
||||
DataReader.getMessageSampleForSchemaVersion
|
||||
}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
hasAnyCurrentCustomChatFolders={hasAnyCurrentCustomChatFolders}
|
||||
hasAudioNotifications={hasAudioNotifications}
|
||||
hasAutoConvertEmoji={hasAutoConvertEmoji}
|
||||
hasAutoDownloadUpdate={hasAutoDownloadUpdate}
|
||||
hasAutoLaunch={hasAutoLaunch}
|
||||
hasKeepMutedChatsArchived={hasKeepMutedChatsArchived}
|
||||
hasCallNotifications={hasCallNotifications}
|
||||
hasCallRingtoneNotification={hasCallRingtoneNotification}
|
||||
hasContentProtection={hasContentProtection}
|
||||
hasCountMutedConversations={hasCountMutedConversations}
|
||||
hasFailedStorySends={hasFailedStorySends}
|
||||
hasHideMenuBar={hasHideMenuBar}
|
||||
hasIncomingCallNotifications={hasIncomingCallNotifications}
|
||||
hasKeyTransparencyDisabled={hasKeyTransparencyDisabled}
|
||||
hasLinkPreviews={hasLinkPreviews}
|
||||
hasMediaCameraPermissions={hasMediaCameraPermissions}
|
||||
hasMediaPermissions={hasMediaPermissions}
|
||||
hasMessageAudio={hasMessageAudio}
|
||||
hasMinimizeToAndStartInSystemTray={hasMinimizeToAndStartInSystemTray}
|
||||
hasMinimizeToSystemTray={hasMinimizeToSystemTray}
|
||||
hasNotificationAttention={hasNotificationAttention}
|
||||
hasNotifications={hasNotifications}
|
||||
hasPreferContactAvatars={hasPreferContactAvatars}
|
||||
hasReadReceipts={hasReadReceipts}
|
||||
hasRelayCalls={hasRelayCalls}
|
||||
hasSealedSenderIndicators={hasSealedSenderIndicators}
|
||||
hasSpellCheck={hasSpellCheck}
|
||||
hasStoriesDisabled={hasStoriesDisabled}
|
||||
hasTextFormatting={hasTextFormatting}
|
||||
hasTypingIndicators={hasTypingIndicators}
|
||||
i18n={i18n}
|
||||
initialSpellCheckSetting={initialSpellCheckSetting}
|
||||
isAutoDownloadUpdatesSupported={isAutoDownloadUpdatesSupported}
|
||||
isAutoLaunchSupported={isAutoLaunchSupported}
|
||||
isContentProtectionNeeded={isContentProtectionNeeded}
|
||||
isContentProtectionSupported={isContentProtectionSupported}
|
||||
isHideMenuBarSupported={isHideMenuBarSupported}
|
||||
isKeyTransparencyAvailable={isKeyTransparencyAvailable}
|
||||
isMinimizeToAndStartInSystemTraySupported={
|
||||
isMinimizeToAndStartInSystemTraySupported
|
||||
}
|
||||
isNotificationAttentionSupported={isNotificationAttentionSupported}
|
||||
isPlaintextExportEnabled={isPlaintextExportEnabled}
|
||||
isSyncSupported={isSyncSupported}
|
||||
isSystemTraySupported={isSystemTraySupported}
|
||||
isInternalUser={isInternalUser}
|
||||
lastLocalBackup={lastLocalBackup}
|
||||
lastSyncTime={lastSyncTime}
|
||||
localBackupFolder={localBackupFolder}
|
||||
localeOverride={localeOverride}
|
||||
makeSyncRequest={makeSyncRequest}
|
||||
me={me}
|
||||
navTabsCollapsed={navTabsCollapsed}
|
||||
notificationContent={notificationContent}
|
||||
notificationProfileCount={notificationProfileCount}
|
||||
onAudioNotificationsChange={onAudioNotificationsChange}
|
||||
onAutoConvertEmojiChange={onAutoConvertEmojiChange}
|
||||
onAutoDownloadAttachmentChange={onAutoDownloadAttachmentChange}
|
||||
onAutoDownloadUpdateChange={onAutoDownloadUpdateChange}
|
||||
onAutoLaunchChange={onAutoLaunchChange}
|
||||
onBackupKeyViewed={onBackupKeyViewed}
|
||||
onCallNotificationsChange={onCallNotificationsChange}
|
||||
onCallRingtoneNotificationChange={onCallRingtoneNotificationChange}
|
||||
onContentProtectionChange={onContentProtectionChange}
|
||||
onCountMutedConversationsChange={onCountMutedConversationsChange}
|
||||
onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange}
|
||||
onHasKeyTransparencyDisabledChanged={
|
||||
onHasKeyTransparencyDisabledChanged
|
||||
}
|
||||
onHasStoriesDisabledChanged={onHasStoriesDisabledChanged}
|
||||
onHideMenuBarChange={onHideMenuBarChange}
|
||||
onIncomingCallNotificationsChange={onIncomingCallNotificationsChange}
|
||||
onKeepMutedChatsArchivedChange={onKeepMutedChatsArchivedChange}
|
||||
onLastSyncTimeChange={onLastSyncTimeChange}
|
||||
onLinkPreviewsChange={onLinkPreviewsChange}
|
||||
onLocaleChange={onLocaleChange}
|
||||
onMediaCameraPermissionsChange={onMediaCameraPermissionsChange}
|
||||
onMediaPermissionsChange={onMediaPermissionsChange}
|
||||
onMessageAudioChange={onMessageAudioChange}
|
||||
onMinimizeToAndStartInSystemTrayChange={
|
||||
onMinimizeToAndStartInSystemTrayChange
|
||||
}
|
||||
onMinimizeToSystemTrayChange={onMinimizeToSystemTrayChange}
|
||||
onNotificationAttentionChange={onNotificationAttentionChange}
|
||||
onNotificationContentChange={onNotificationContentChange}
|
||||
onNotificationsChange={onNotificationsChange}
|
||||
onStartUpdate={startUpdate}
|
||||
onPreferContactAvatarsChange={onPreferContactAvatarsChange}
|
||||
onReadReceiptsChange={onReadReceiptsChange}
|
||||
onRelayCallsChange={onRelayCallsChange}
|
||||
onSealedSenderIndicatorsChange={onSealedSenderIndicatorsChange}
|
||||
onSelectedCameraChange={onSelectedCameraChange}
|
||||
onSelectedMicrophoneChange={onSelectedMicrophoneChange}
|
||||
onSelectedSpeakerChange={onSelectedSpeakerChange}
|
||||
onSentMediaQualityChange={onSentMediaQualityChange}
|
||||
onSpellCheckChange={onSpellCheckChange}
|
||||
onTextFormattingChange={onTextFormattingChange}
|
||||
onThemeChange={onThemeChange}
|
||||
onToggleNavTabsCollapse={toggleNavTabsCollapse}
|
||||
onTypingIndicatorsChange={onTypingIndicatorsChange}
|
||||
onUniversalExpireTimerChange={onUniversalExpireTimerChange}
|
||||
onWhoCanFindMeChange={onWhoCanFindMeChange}
|
||||
onWhoCanSeeMeChange={onWhoCanSeeMeChange}
|
||||
onZoomFactorChange={onZoomFactorChange}
|
||||
openFileInFolder={openFileInFolder}
|
||||
osName={osName}
|
||||
otherTabsUnreadStats={otherTabsUnreadStats}
|
||||
settingsLocation={settingsLocation}
|
||||
pickLocalBackupFolder={pickLocalBackupFolder}
|
||||
preferredSystemLocales={preferredSystemLocales}
|
||||
preferredWidthFromStorage={preferredWidthFromStorage}
|
||||
refreshCloudBackupStatus={refreshCloudBackupStatus}
|
||||
refreshBackupSubscriptionStatus={refreshBackupSubscriptionStatus}
|
||||
removeCustomColorOnConversations={removeCustomColorOnConversations}
|
||||
removeCustomColor={removeCustomColor}
|
||||
renderDonationsPane={renderDonationsPane}
|
||||
renderNotificationProfilesHome={renderNotificationProfilesHome}
|
||||
renderNotificationProfilesCreateFlow={
|
||||
renderNotificationProfilesCreateFlow
|
||||
}
|
||||
renderProfileEditor={renderProfileEditor}
|
||||
renderToastManager={renderToastManagerWithoutMegaphone}
|
||||
renderUpdateDialog={renderUpdateDialog}
|
||||
renderPreferencesChatFoldersPage={renderPreferencesChatFoldersPage}
|
||||
renderPreferencesEditChatFolderPage={
|
||||
renderPreferencesEditChatFolderPage
|
||||
}
|
||||
previouslyViewedBackupKeyHash={previouslyViewedBackupKeyHash}
|
||||
promptOSAuth={promptOSAuth}
|
||||
resetAllChatColors={resetAllChatColors}
|
||||
resetDefaultChatColor={resetDefaultChatColor}
|
||||
resolvedLocale={resolvedLocale}
|
||||
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
|
||||
resumeBackupMediaDownload={resumeBackupMediaDownload}
|
||||
pauseBackupMediaDownload={pauseBackupMediaDownload}
|
||||
cancelBackupMediaDownload={cancelBackupMediaDownload}
|
||||
selectedCamera={selectedCamera}
|
||||
selectedMicrophone={selectedMicrophone}
|
||||
selectedSpeaker={selectedSpeaker}
|
||||
sentMediaQualitySetting={sentMediaQualitySetting}
|
||||
setGlobalDefaultConversationColor={setGlobalDefaultConversationColor}
|
||||
setSettingsLocation={setSettingsLocation}
|
||||
shouldShowUpdateDialog={shouldShowUpdateDialog}
|
||||
showToast={showToast}
|
||||
startLocalBackupExport={startLocalBackupExport}
|
||||
startPlaintextExport={startPlaintextExport}
|
||||
theme={theme}
|
||||
themeSetting={themeSetting}
|
||||
universalExpireTimer={universalExpireTimer}
|
||||
validateBackup={validateBackup}
|
||||
whoCanFindMe={whoCanFindMe}
|
||||
whoCanSeeMe={whoCanSeeMe}
|
||||
zoomFactor={zoomFactor}
|
||||
donationReceipts={donationReceipts}
|
||||
internalAddDonationReceipt={internalAddDonationReceipt}
|
||||
saveAttachmentToDisk={saveAttachmentToDisk}
|
||||
generateDonationReceiptBlob={generateDonationReceiptBlob}
|
||||
addVisibleMegaphone={addVisibleMegaphone}
|
||||
internalDeleteAllMegaphones={internalDeleteAllMegaphones}
|
||||
__dangerouslyRunAbitraryReadOnlySqlQuery={
|
||||
__dangerouslyRunAbitraryReadOnlySqlQuery
|
||||
}
|
||||
cqsTestMode={cqsTestMode}
|
||||
setCqsTestMode={setCqsTestMode}
|
||||
dredDuration={items.dredDuration}
|
||||
setDredDuration={setDredDuration}
|
||||
setIsDirectVp9Enabled={setIsDirectVp9Enabled}
|
||||
isDirectVp9Enabled={items.isDirectVp9Enabled}
|
||||
setDirectMaxBitrate={setDirectMaxBitrate}
|
||||
directMaxBitrate={items.directMaxBitrate}
|
||||
setIsGroupVp9Enabled={setIsGroupVp9Enabled}
|
||||
isGroupVp9Enabled={items.isGroupVp9Enabled}
|
||||
setGroupMaxBitrate={setGroupMaxBitrate}
|
||||
groupMaxBitrate={items.groupMaxBitrate}
|
||||
sfuUrl={items.sfuUrl}
|
||||
setSfuUrl={setSfuUrl}
|
||||
forceKeyTransparencyCheck={forceKeyTransparencyCheck}
|
||||
keyTransparencySelfHealth={items.keyTransparencySelfHealth}
|
||||
weArePrimaryDevice={weArePrimaryDevice}
|
||||
/>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
import * as Errors from '../types/errors.std.ts';
|
||||
@@ -10,8 +9,7 @@ import { createLogger } from '../logging/log.std.ts';
|
||||
import { ProgressModal } from '../components/ProgressModal.dom.tsx';
|
||||
import { clearTimeoutIfNecessary } from './clearTimeoutIfNecessary.std.ts';
|
||||
import { sleep } from './sleep.std.ts';
|
||||
// oxlint-disable-next-line signal-desktop/no-restricted-paths
|
||||
import { AxoProvider } from '../axo/AxoProvider.dom.tsx';
|
||||
import { AppProvider } from '../windows/AppProvider.dom.tsx';
|
||||
|
||||
const log = createLogger('longRunningTaskWrapper');
|
||||
|
||||
@@ -41,11 +39,9 @@ export async function longRunningTaskWrapper<T>({
|
||||
log.info(`${idLog}: Creating spinner`);
|
||||
progressRoot = createRoot(progressNode);
|
||||
progressRoot.render(
|
||||
<StrictMode>
|
||||
<AxoProvider dir={i18n.getLocaleDirection()}>
|
||||
<ProgressModal i18n={i18n} description={spinnerText} />
|
||||
</AxoProvider>
|
||||
</StrictMode>
|
||||
<AppProvider>
|
||||
<ProgressModal i18n={i18n} description={spinnerText} />
|
||||
</AppProvider>
|
||||
);
|
||||
spinnerStart = Date.now();
|
||||
}, TWO_SECONDS);
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
// Copyright 2015 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
// oxlint-disable-next-line signal-desktop/no-restricted-paths
|
||||
import { ConfirmationDialog } from '../components/ConfirmationDialog.dom.tsx';
|
||||
// oxlint-disable-next-line signal-desktop/no-restricted-paths
|
||||
import { AxoProvider } from '../axo/AxoProvider.dom.tsx';
|
||||
import { AppProvider } from '../windows/AppProvider.dom.tsx';
|
||||
|
||||
type ConfirmationDialogViewProps = {
|
||||
onTopOfEverything?: boolean;
|
||||
@@ -60,38 +58,36 @@ export function showConfirmationDialog(
|
||||
|
||||
confirmationDialogRoot = createRoot(confirmationDialogViewNode);
|
||||
confirmationDialogRoot.render(
|
||||
<StrictMode>
|
||||
<AxoProvider dir={i18n.getLocaleDirection()}>
|
||||
<ConfirmationDialog
|
||||
dialogName={options.dialogName}
|
||||
onTopOfEverything={options.onTopOfEverything}
|
||||
actions={[
|
||||
{
|
||||
action: () => {
|
||||
options.resolve();
|
||||
},
|
||||
style: options.confirmStyle,
|
||||
text: options.okText || i18n('icu:ok'),
|
||||
<AppProvider>
|
||||
<ConfirmationDialog
|
||||
dialogName={options.dialogName}
|
||||
onTopOfEverything={options.onTopOfEverything}
|
||||
actions={[
|
||||
{
|
||||
action: () => {
|
||||
options.resolve();
|
||||
},
|
||||
]}
|
||||
cancelText={options.cancelText || i18n('icu:cancel')}
|
||||
i18n={i18n}
|
||||
onCancel={() => {
|
||||
if (options.reject) {
|
||||
options.reject(
|
||||
new Error('showConfirmationDialog: onCancel called')
|
||||
);
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
removeConfirmationDialog();
|
||||
}}
|
||||
title={options.title}
|
||||
noMouseClose={options.noMouseClose}
|
||||
>
|
||||
{options.description}
|
||||
</ConfirmationDialog>
|
||||
</AxoProvider>
|
||||
</StrictMode>
|
||||
style: options.confirmStyle,
|
||||
text: options.okText || i18n('icu:ok'),
|
||||
},
|
||||
]}
|
||||
cancelText={options.cancelText || i18n('icu:cancel')}
|
||||
i18n={i18n}
|
||||
onCancel={() => {
|
||||
if (options.reject) {
|
||||
options.reject(
|
||||
new Error('showConfirmationDialog: onCancel called')
|
||||
);
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
removeConfirmationDialog();
|
||||
}}
|
||||
title={options.title}
|
||||
noMouseClose={options.noMouseClose}
|
||||
>
|
||||
{options.description}
|
||||
</ConfirmationDialog>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2026 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { memo, StrictMode } from 'react';
|
||||
import { AxoProvider } from '../axo/AxoProvider.dom.tsx';
|
||||
import type { AxoProviderProps } from '../axo/AxoProvider.dom.tsx';
|
||||
|
||||
export type AppProviderProps = Readonly<{
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
export const AppProvider: FC<AppProviderProps> = memo(
|
||||
function AppProvider(props) {
|
||||
const { i18n } = window.SignalContext;
|
||||
const dir = window.SignalContext.getResolvedMessagesLocaleDirection();
|
||||
|
||||
const messages: AxoProviderProps['messages'] = {
|
||||
'AxoButton.Pending': i18n('icu:AxoButton.Pending'),
|
||||
'AxoDialog.Back': i18n('icu:AxoDialog.Back'),
|
||||
'AxoDialog.Close': i18n('icu:AxoDialog.Close'),
|
||||
'AxoTextField.Clear': i18n('icu:AxoTextField.Clear'),
|
||||
};
|
||||
|
||||
return (
|
||||
<StrictMode>
|
||||
<AxoProvider dir={dir} messages={messages}>
|
||||
{props.children}
|
||||
</AxoProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -1,13 +1,12 @@
|
||||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../sandboxedInit.dom.ts';
|
||||
import { About } from '../../components/About.dom.tsx';
|
||||
import { strictAssert } from '../../util/assert.std.ts';
|
||||
import { AxoProvider } from '../../axo/AxoProvider.dom.tsx';
|
||||
import { AppProvider } from '../AppProvider.dom.tsx';
|
||||
|
||||
const { AboutWindowProps } = window.Signal;
|
||||
const { i18n } = window.SignalContext;
|
||||
@@ -18,18 +17,14 @@ const app = document.getElementById('app');
|
||||
strictAssert(app != null, 'No #app');
|
||||
|
||||
createRoot(app).render(
|
||||
<StrictMode>
|
||||
<AxoProvider
|
||||
dir={window.SignalContext.getResolvedMessagesLocaleDirection()}
|
||||
>
|
||||
<About
|
||||
closeAbout={() => window.SignalContext.executeMenuRole('close')}
|
||||
appEnv={AboutWindowProps.appEnv}
|
||||
platform={AboutWindowProps.platform}
|
||||
arch={AboutWindowProps.arch}
|
||||
i18n={i18n}
|
||||
version={window.SignalContext.getVersion()}
|
||||
/>
|
||||
</AxoProvider>
|
||||
</StrictMode>
|
||||
<AppProvider>
|
||||
<About
|
||||
closeAbout={() => window.SignalContext.executeMenuRole('close')}
|
||||
appEnv={AboutWindowProps.appEnv}
|
||||
platform={AboutWindowProps.platform}
|
||||
arch={AboutWindowProps.arch}
|
||||
i18n={i18n}
|
||||
version={window.SignalContext.getVersion()}
|
||||
/>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// Copyright 2026 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { StrictMode, useSyncExternalStore, type JSX } from 'react';
|
||||
import { useSyncExternalStore, type JSX } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '../sandboxedInit.dom.ts';
|
||||
import { CallDiagnosticWindow } from '../../components/CallDiagnosticWindow.dom.tsx';
|
||||
import { strictAssert } from '../../util/assert.std.ts';
|
||||
import { AxoProvider } from '../../axo/AxoProvider.dom.tsx';
|
||||
import { AppProvider } from '../AppProvider.dom.tsx';
|
||||
|
||||
const { CallDiagnosticWindowProps } = window.Signal;
|
||||
strictAssert(CallDiagnosticWindowProps, 'window values not provided');
|
||||
@@ -33,11 +33,7 @@ const app = document.getElementById('app');
|
||||
strictAssert(app != null, 'No #app');
|
||||
|
||||
createRoot(app).render(
|
||||
<StrictMode>
|
||||
<AxoProvider
|
||||
dir={window.SignalContext.getResolvedMessagesLocaleDirection()}
|
||||
>
|
||||
<App />
|
||||
</AxoProvider>
|
||||
</StrictMode>
|
||||
<AppProvider>
|
||||
<App />
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '../sandboxedInit.dom.ts';
|
||||
import { DebugLogWindow } from '../../components/DebugLogWindow.dom.tsx';
|
||||
import { strictAssert } from '../../util/assert.std.ts';
|
||||
import { AxoProvider } from '../../axo/AxoProvider.dom.tsx';
|
||||
import { AppProvider } from '../AppProvider.dom.tsx';
|
||||
|
||||
const { DebugLogWindowProps } = window.Signal;
|
||||
const { i18n } = window.SignalContext;
|
||||
@@ -17,18 +16,14 @@ const app = document.getElementById('app');
|
||||
strictAssert(app != null, 'No #app');
|
||||
|
||||
createRoot(app).render(
|
||||
<StrictMode>
|
||||
<AxoProvider
|
||||
dir={window.SignalContext.getResolvedMessagesLocaleDirection()}
|
||||
>
|
||||
<DebugLogWindow
|
||||
closeWindow={() => window.SignalContext.executeMenuRole('close')}
|
||||
downloadLog={DebugLogWindowProps.downloadLog}
|
||||
i18n={i18n}
|
||||
fetchLogs={DebugLogWindowProps.fetchLogs}
|
||||
uploadLogs={DebugLogWindowProps.uploadLogs}
|
||||
mode={DebugLogWindowProps.mode}
|
||||
/>
|
||||
</AxoProvider>
|
||||
</StrictMode>
|
||||
<AppProvider>
|
||||
<DebugLogWindow
|
||||
closeWindow={() => window.SignalContext.executeMenuRole('close')}
|
||||
downloadLog={DebugLogWindowProps.downloadLog}
|
||||
i18n={i18n}
|
||||
fetchLogs={DebugLogWindowProps.fetchLogs}
|
||||
uploadLogs={DebugLogWindowProps.uploadLogs}
|
||||
mode={DebugLogWindowProps.mode}
|
||||
/>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../sandboxedInit.dom.ts';
|
||||
import { PermissionsPopup } from '../../components/PermissionsPopup.dom.tsx';
|
||||
import { strictAssert } from '../../util/assert.std.ts';
|
||||
import { AxoProvider } from '../../axo/AxoProvider.dom.tsx';
|
||||
import { AppProvider } from '../AppProvider.dom.tsx';
|
||||
|
||||
const { PermissionsWindowProps } = window.Signal;
|
||||
const { i18n } = window.SignalContext;
|
||||
@@ -31,16 +28,12 @@ const app = document.getElementById('app');
|
||||
strictAssert(app != null, 'No #app');
|
||||
|
||||
createRoot(app).render(
|
||||
<StrictMode>
|
||||
<AxoProvider
|
||||
dir={window.SignalContext.getResolvedMessagesLocaleDirection()}
|
||||
>
|
||||
<PermissionsPopup
|
||||
i18n={i18n}
|
||||
message={message}
|
||||
onAccept={PermissionsWindowProps.onAccept}
|
||||
onClose={PermissionsWindowProps.onClose}
|
||||
/>
|
||||
</AxoProvider>
|
||||
</StrictMode>
|
||||
<AppProvider>
|
||||
<PermissionsPopup
|
||||
i18n={i18n}
|
||||
message={message}
|
||||
onAccept={PermissionsWindowProps.onAccept}
|
||||
onClose={PermissionsWindowProps.onClose}
|
||||
/>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../sandboxedInit.dom.ts';
|
||||
import { CallingScreenSharingController } from '../../components/CallingScreenSharingController.dom.tsx';
|
||||
import { strictAssert } from '../../util/assert.std.ts';
|
||||
import { drop } from '../../util/drop.std.ts';
|
||||
import { parseEnvironment, setEnvironment } from '../../environment.std.ts';
|
||||
import { AxoProvider } from '../../axo/AxoProvider.dom.tsx';
|
||||
import { AppProvider } from '../AppProvider.dom.tsx';
|
||||
|
||||
const { ScreenShareWindowProps } = window.Signal;
|
||||
const { i18n } = window.SignalContext;
|
||||
@@ -33,21 +30,17 @@ function render() {
|
||||
strictAssert(app != null, 'No #app');
|
||||
|
||||
createRoot(app).render(
|
||||
<StrictMode>
|
||||
<AxoProvider
|
||||
dir={window.SignalContext.getResolvedMessagesLocaleDirection()}
|
||||
>
|
||||
<div className="App dark-theme">
|
||||
<CallingScreenSharingController
|
||||
i18n={i18n}
|
||||
onCloseController={onCloseController}
|
||||
onStopSharing={ScreenShareWindowProps.onStopSharing}
|
||||
status={ScreenShareWindowProps.getStatus()}
|
||||
presentedSourceName={ScreenShareWindowProps.presentedSourceName}
|
||||
/>
|
||||
</div>
|
||||
</AxoProvider>
|
||||
</StrictMode>
|
||||
<AppProvider>
|
||||
<div className="App dark-theme">
|
||||
<CallingScreenSharingController
|
||||
i18n={i18n}
|
||||
onCloseController={onCloseController}
|
||||
onStopSharing={ScreenShareWindowProps.onStopSharing}
|
||||
status={ScreenShareWindowProps.getStatus()}
|
||||
presentedSourceName={ScreenShareWindowProps.presentedSourceName}
|
||||
/>
|
||||
</div>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
render();
|
||||
|
||||
Reference in New Issue
Block a user