diff --git a/.eslint/rules/file-suffix.js b/.eslint/rules/file-suffix.js index 20666b9c45..6217847366 100644 --- a/.eslint/rules/file-suffix.js +++ b/.eslint/rules/file-suffix.js @@ -150,6 +150,7 @@ const DOM_PACKAGES = new Set([ 'blob-util', 'blueimp-load-image', 'copy-text-to-clipboard', + 'dom-accessibility-api', 'fabric', 'focus-trap-react', 'radix-ui', diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 66b526bc2b..3cf767d10f 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -3643,6 +3643,30 @@ Signal Desktop makes use of the following open source projects. TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## dom-accessibility-api + + MIT License + + Copyright (c) 2020 Sebastian Silbermann + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + ## emoji-datasource The MIT License (MIT) diff --git a/package.json b/package.json index 582de1cc4e..7e8dea91d7 100644 --- a/package.json +++ b/package.json @@ -156,6 +156,7 @@ "credit-card-type": "10.0.2", "dashdash": "2.0.0", "direction": "1.0.4", + "dom-accessibility-api": "0.7.0", "emoji-datasource": "16.0.0", "emoji-datasource-apple": "16.0.0", "emoji-regex": "10.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 991018eac1..479534811d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -200,6 +200,9 @@ importers: direction: specifier: 1.0.4 version: 1.0.4 + dom-accessibility-api: + specifier: 0.7.0 + version: 0.7.0 emoji-datasource: specifier: 16.0.0 version: 16.0.0 @@ -5739,6 +5742,9 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-accessibility-api@0.7.0: + resolution: {integrity: sha512-LjjdFmd9AITAet3Hy6Y6rwB7Sq1+x5NiwbOpnkLHC1bCXJqJKiV9DyppSSWobuSKvjKXt9G2u3hW402MPt6m+g==} + dom-converter@0.2.0: resolution: {integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==} @@ -16929,6 +16935,8 @@ snapshots: dom-accessibility-api@0.6.3: {} + dom-accessibility-api@0.7.0: {} + dom-converter@0.2.0: dependencies: utila: 0.4.0 diff --git a/stylesheets/tailwind-config.css b/stylesheets/tailwind-config.css index 989b6e7b81..d9c85d1598 100644 --- a/stylesheets/tailwind-config.css +++ b/stylesheets/tailwind-config.css @@ -277,7 +277,7 @@ --type-font-weight-title-small: var(--font-weight-semibold); --type-font-weight-body-large: var(--font-weight-regular); --type-font-weight-body-medium: var(--font-weight-regular); - --type-font-weight-body-small: var(--font-weight-medium); + --type-font-weight-body-small: var(--font-weight-regular); --type-font-weight-caption: var(--font-weight-regular); /* letter-spacing */ --tracking-*: initial; /* reset defaults */ diff --git a/ts/axo/AriaClickable.dom.stories.tsx b/ts/axo/AriaClickable.dom.stories.tsx index d667d6ef04..09cc8adc9a 100644 --- a/ts/axo/AriaClickable.dom.stories.tsx +++ b/ts/axo/AriaClickable.dom.stories.tsx @@ -78,11 +78,7 @@ function CardButton(props: { }) { return ( - + {props.children} diff --git a/ts/axo/AxoAlertDialog.dom.stories.tsx b/ts/axo/AxoAlertDialog.dom.stories.tsx index 2b15c4f20a..13a672909d 100644 --- a/ts/axo/AxoAlertDialog.dom.stories.tsx +++ b/ts/axo/AxoAlertDialog.dom.stories.tsx @@ -20,7 +20,12 @@ const EXAMPLE_TITLE_LONG = ( ); -const EXAMPLE_DESCRIPTION = <>Exporting chat; +const EXAMPLE_DESCRIPTION = ( + <> + Your chat will be downloaded in the background. You can continue to use + Signal during this process. + +); const EXAMPLE_DESCRIPTION_LONG = ( <> Lorem ipsum dolor sit amet consectetur adipisicing elit. Nobis, amet aut @@ -72,12 +77,11 @@ function Template(props: { return ( - + Open ; export const Content: FC = memo(props => { - const sizeConfig = AxoBaseDialog.ContentSizes[props.size]; const handleContentEscapeEvent = useContentEscapeBehavior(props.escape); return ( - - - - - {props.children} - - - - + + + + {props.children} + + + ); }); @@ -113,14 +112,8 @@ export namespace AxoAlertDialog { }>; export const Body: FC = memo(props => { - const contentSize = useContentSize(); - const contentSizeConfig = AxoBaseDialog.ContentSizes[contentSize]; - return ( - + @@ -208,7 +201,7 @@ export namespace AxoAlertDialog { export const Cancel: FC = memo(props => { return ( - + {props.children} @@ -239,8 +232,8 @@ export namespace AxoAlertDialog { variant={props.variant} symbol={props.symbol} arrow={props.arrow} - size="medium" - width="fill" + size="md" + width="full" > {props.children} diff --git a/ts/axo/AxoButton.dom.stories.tsx b/ts/axo/AxoButton.dom.stories.tsx index 1a777addc8..bd7b7b12db 100644 --- a/ts/axo/AxoButton.dom.stories.tsx +++ b/ts/axo/AxoButton.dom.stories.tsx @@ -145,7 +145,7 @@ const LONG_TEXT = ( function Fit(props: { longText?: boolean }) { return ( - + Fit {props.longText && LONG_TEXT} ); @@ -153,15 +153,15 @@ function Fit(props: { longText?: boolean }) { function Grow(props: { longText?: boolean }) { return ( - + Grow {props.longText && LONG_TEXT} ); } -function Fill(props: { longText?: boolean }) { +function Full(props: { longText?: boolean }) { return ( - + Fill {props.longText && LONG_TEXT} ); @@ -180,7 +180,7 @@ function WidthTestTemplate(props: { <> - + )} @@ -239,26 +239,26 @@ function WidthTestTemplate(props: {

Fill

{props.children( <> - - - + + + )} -

Fill: With long text

+

Full: With long text

{props.children( <> - - - + + + )} -

Fill: With mixed length texts

+

Full: With mixed length texts

{props.children( <> - - - + + + )} diff --git a/ts/axo/AxoButton.dom.tsx b/ts/axo/AxoButton.dom.tsx index 8dd7b318f3..76e372fed8 100644 --- a/ts/axo/AxoButton.dom.tsx +++ b/ts/axo/AxoButton.dom.tsx @@ -128,9 +128,9 @@ const AxoButtonVariants = { }; const AxoButtonSizes = { - large: tw('min-w-16 px-4 py-2 type-body-medium font-medium'), - medium: tw('min-w-14 px-3 py-1.5 type-body-medium font-medium'), - small: tw('min-w-12 px-2 py-1 type-body-small font-medium'), + 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; type BaseButtonAttrs = Omit< @@ -171,9 +171,9 @@ const AxoButtonSpinnerSizes: Record< AxoButtonSize, { size: number; strokeWidth: number } > = { - large: { size: 20, strokeWidth: 2 }, - medium: { size: 20, strokeWidth: 2 }, - small: { size: 16, strokeWidth: 1.5 }, + lg: { size: 20, strokeWidth: 2 }, + md: { size: 20, strokeWidth: 2 }, + sm: { size: 16, strokeWidth: 1.5 }, }; type ExperimentalButtonSpinnerProps = Readonly<{ @@ -204,7 +204,7 @@ export namespace AxoButton { export type Variant = AxoButtonVariant; export type Size = AxoButtonSize; - export type Width = 'fit' | 'grow' | 'fill'; + export type Width = 'fit' | 'grow' | 'full'; const Widths: Record = { /* Always try to fit to the content of the button */ @@ -212,7 +212,7 @@ export namespace AxoButton { /* Allow the button to grow within a flex container */ grow: tw('grow'), /* Always try to fill the available space */ - fill: tw('w-full'), + full: tw('w-full'), }; export type RootProps = BaseButtonAttrs & diff --git a/ts/axo/AxoCheckbox.dom.tsx b/ts/axo/AxoCheckbox.dom.tsx index f8392aecad..6ccdb974df 100644 --- a/ts/axo/AxoCheckbox.dom.tsx +++ b/ts/axo/AxoCheckbox.dom.tsx @@ -25,7 +25,7 @@ export namespace AxoCheckbox { disabled={props.disabled} required={props.required} className={tw( - 'flex size-5 items-center justify-center rounded-full', + 'flex size-5 items-center justify-center rounded-full leading-none', 'border border-border-primary inset-shadow-on-color', 'data-[state=unchecked]:bg-fill-primary', 'data-[state=unchecked]:pressed:bg-fill-primary-pressed', diff --git a/ts/axo/AxoDialog.dom.stories.tsx b/ts/axo/AxoDialog.dom.stories.tsx index eb0af60b06..9f434f54d8 100644 --- a/ts/axo/AxoDialog.dom.stories.tsx +++ b/ts/axo/AxoDialog.dom.stories.tsx @@ -1,12 +1,14 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { ReactNode } from 'react'; -import React, { useState } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; +import React, { useId, useMemo, useState } from 'react'; import type { Meta } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import { AxoDialog } from './AxoDialog.dom.js'; import { AxoButton } from './AxoButton.dom.js'; import { tw } from './tw.dom.js'; +import { AxoCheckbox } from './AxoCheckbox.dom.js'; +import { getScrollbarGutters } from './_internal/scrollbars.dom.js'; export default { title: 'Axo/AxoDialog', @@ -46,7 +48,7 @@ function Template(props: { return ( - + Open Dialog @@ -143,3 +145,219 @@ export function FooterContentLongAndTight(): JSX.Element { ); } + +function Spacer(props: { height: 8 | 12 }) { + return
; +} + +function TextInputField(props: { placeholder: string }) { + const style = useMemo(() => { + const bodyPadding = 24; + const inputPadding = 16; + + return { marginInline: inputPadding - bodyPadding }; + }, []); + + return ( +
+ +
+ ); +} + +export function ExampleNicknameAndNoteDialog(): JSX.Element { + const [open, setOpen] = useState(true); + return ( + + + + Open Dialog + + + + + Nickname + + + +

+ Nicknames & notes are stored with Signal and end-to-end + encrypted. They are only visible to you. +

+
+ + + + + + + + + + Cancel + + + Save + + + + + + ); +} + +function CheckboxField(props: { label: string }) { + const id = useId(); + const [checked, setChecked] = useState(false); + + return ( +
+ + +
+ ); +} + +export function ExampleMuteNotificationsDialog(): JSX.Element { + const [open, setOpen] = useState(true); + return ( + + + + Open Dialog + + + + + Mute notifications + + + + + + + + + + + + + + + Cancel + + + Save + + + + + + ); +} + +function ExampleItem(props: { label: string; description: string }) { + const labelId = useId(); + const descriptionId = useId(); + + const style = useMemo((): CSSProperties => { + return { + paddingInline: 24 - getScrollbarGutters('thin', 'custom').vertical, + }; + }, []); + + return ( +
+
+ {props.label} +
+
+ {props.description} +
+
+ ); +} + +export function ExampleLanguageDialog(): JSX.Element { + const [open, setOpen] = useState(true); + return ( + + + + Open Dialog + + + + + Language + + + + + + +
+ + + + + + + + + +
+
+ + + + Cancel + + + Set + + + +
+
+ ); +} diff --git a/ts/axo/AxoDialog.dom.tsx b/ts/axo/AxoDialog.dom.tsx index 45f9286647..8fba78a3c7 100644 --- a/ts/axo/AxoDialog.dom.tsx +++ b/ts/axo/AxoDialog.dom.tsx @@ -19,30 +19,30 @@ import { AxoButton } from './AxoButton.dom.js'; const Namespace = 'AxoDialog'; -const { useContentEscapeBehavior, useContentSize } = AxoBaseDialog; +const { useContentEscapeBehavior } = AxoBaseDialog; -// We want to have 25px of padding on either side of header/body/footer, but +// We want to have 24px of padding on either side of header/body/footer, but // it's import that we remain aligned with the vertical scrollbar gutters that // we need to measure in the browser to know the value of. // // Chrome currently renders vertical scrollbars as 11px with // `scrollbar-width: thin` but that could change someday or based on some OS // settings. So we'll target 24px but we'll tolerate different values. -const SCROLLBAR_WIDTH_EXPECTED = 11; /* (keep in sync with chromium) */ -const SCROLLBAR_WIDTH_ACTUAL = getScrollbarGutters('thin', 'custom').vertical; +function getPadding(target: number, scrollbars: boolean): number { + const scrollbarWidthExpected = 11; + const paddingBeforeScrollbarWidth = target - scrollbarWidthExpected; -const DIALOG_PADDING_TARGET = 20; + if (scrollbars) { + // If this element has scrollbars we should just rely on the rendered gutter + return paddingBeforeScrollbarWidth; + } -const DIALOG_PADDING_BEFORE_SCROLLBAR_WIDTH = - DIALOG_PADDING_TARGET - SCROLLBAR_WIDTH_EXPECTED; + const scrollbarWidthActual = getScrollbarGutters('thin', 'custom').vertical; -const DIALOG_PADDING_PLUS_SCROLLBAR_WIDTH = - SCROLLBAR_WIDTH_ACTUAL + DIALOG_PADDING_BEFORE_SCROLLBAR_WIDTH; - -const DIALOG_HEADER_PADDING_BLOCK = 10; - -const DIALOG_HEADER_ICON_BUTTON_MARGIN = - DIALOG_HEADER_PADDING_BLOCK - DIALOG_PADDING_PLUS_SCROLLBAR_WIDTH; + // If this element doesn't have scrollbars, we need to add the exact value of + // the actual scrollbar gutter + return scrollbarWidthActual + paddingBeforeScrollbarWidth; +} export namespace AxoDialog { /** @@ -86,31 +86,44 @@ export namespace AxoDialog { * ------------------------------ */ - export type ContentSize = AxoBaseDialog.ContentSize; + type ContentSizeConfig = Readonly<{ + width: number; + minWidth: number; + }>; + + const ContentSizes: Record = { + sm: { width: 360, minWidth: 360 }, + md: { width: 420, minWidth: 360 }, + lg: { width: 720, minWidth: 360 }, + }; + + export type ContentSize = 'sm' | 'md' | 'lg'; export type ContentEscape = AxoBaseDialog.ContentEscape; - export type ContentProps = AxoBaseDialog.ContentProps; + export type ContentProps = Readonly<{ + size: ContentSize; + escape: ContentEscape; + children: ReactNode; + }>; export const Content: FC = memo(props => { - const sizeConfig = AxoBaseDialog.ContentSizes[props.size]; + const sizeConfig = ContentSizes[props.size]; const handleContentEscapeEvent = useContentEscapeBehavior(props.escape); return ( - - - - - {props.children} - - - - + + + + {props.children} + + + ); }); @@ -128,14 +141,13 @@ export namespace AxoDialog { export const Header: FC = memo(props => { const style = useMemo(() => { return { - paddingBlock: DIALOG_HEADER_PADDING_BLOCK, - paddingInline: DIALOG_PADDING_PLUS_SCROLLBAR_WIDTH, + paddingInline: getPadding(10, false), }; }, []); return (
- + ); } @@ -186,17 +198,25 @@ export namespace AxoDialog { */ export type TitleProps = Readonly<{ + screenReaderOnly?: boolean; children: ReactNode; }>; export const Title: FC = memo(props => { + const style = useMemo(() => { + return { + paddingInline: 24 - getPadding(10, false), + }; + }, []); return ( {props.children} @@ -215,11 +235,8 @@ export namespace AxoDialog { }>; export const Back: FC = memo(props => { - const style = useMemo((): CSSProperties => { - return { marginInlineStart: DIALOG_HEADER_ICON_BUTTON_MARGIN }; - }, []); return ( -
+
; export const Close: FC = memo(props => { - const style = useMemo((): CSSProperties => { - return { marginInlineEnd: DIALOG_HEADER_ICON_BUTTON_MARGIN }; - }, []); return ( -
+
@@ -254,6 +268,23 @@ export namespace AxoDialog { Close.displayName = `${Namespace}.Close`; + export type ExperimentalSearchProps = Readonly<{ + children: ReactNode; + }>; + + export const ExperimentalSearch: FC = memo(props => { + const style = useMemo(() => { + return { paddingInline: getPadding(16, false) }; + }, []); + return ( +
+ {props.children} +
+ ); + }); + + ExperimentalSearch.displayName = `${Namespace}.ExperimentalSearch`; + /** * Component: * --------------------------- @@ -268,22 +299,18 @@ export namespace AxoDialog { export const Body: FC = memo(props => { const { padding = 'normal' } = props; - const contentSize = useContentSize(); - const contentSizeConfig = AxoBaseDialog.ContentSizes[contentSize]; const style = useMemo((): CSSProperties => { return { - paddingInline: - padding === 'normal' - ? DIALOG_PADDING_BEFORE_SCROLLBAR_WIDTH - : undefined, + paddingInline: padding === 'normal' ? getPadding(24, true) : undefined, }; }, [padding]); return ( @@ -325,13 +352,13 @@ export namespace AxoDialog { export const Footer: FC = memo(props => { const style = useMemo((): CSSProperties => { return { - paddingInline: DIALOG_PADDING_PLUS_SCROLLBAR_WIDTH, + paddingInline: getPadding(12, false), }; }, []); return (
{props.children} @@ -351,6 +378,9 @@ export namespace AxoDialog { }>; export const FooterContent: FC = memo(props => { + const style = useMemo(() => { + return { paddingInlineStart: 24 - getPadding(12, false) }; + }, []); return (
{props.children}
@@ -422,7 +453,7 @@ export namespace AxoDialog { variant={props.variant} symbol={props.symbol} arrow={props.arrow} - size="medium" + size="md" width="grow" > {props.children} diff --git a/ts/axo/AxoDropdownMenu.dom.stories.tsx b/ts/axo/AxoDropdownMenu.dom.stories.tsx index b7af09173f..87652c63bf 100644 --- a/ts/axo/AxoDropdownMenu.dom.stories.tsx +++ b/ts/axo/AxoDropdownMenu.dom.stories.tsx @@ -28,7 +28,7 @@ export function Basic(): JSX.Element { - + Open Dropdown Menu @@ -114,7 +114,7 @@ export function WithHeader(): JSX.Element { - + Open Dropdown Menu @@ -222,7 +222,7 @@ export function StressTestLongText(): JSX.Element { - + Open Dropdown Menu diff --git a/ts/axo/AxoDropdownMenu.dom.tsx b/ts/axo/AxoDropdownMenu.dom.tsx index 6115f8bcac..7da6ce758d 100644 --- a/ts/axo/AxoDropdownMenu.dom.tsx +++ b/ts/axo/AxoDropdownMenu.dom.tsx @@ -1,8 +1,9 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { memo, useId } from 'react'; +import React, { memo, useEffect, useId, useRef } from 'react'; import { DropdownMenu } from 'radix-ui'; import type { FC, ReactNode } from 'react'; +import { getRole, computeAccessibleName } from 'dom-accessibility-api'; import { AxoSymbol } from './AxoSymbol.dom.js'; import { AxoBaseMenu } from './_internal/AxoBaseMenu.dom.js'; import { tw } from './tw.dom.js'; @@ -11,6 +12,7 @@ import { useAriaLabellingContext, useCreateAriaLabellingContext, } from './_internal/AriaLabellingContext.dom.js'; +import { assert } from './_internal/assert.dom.js'; const Namespace = 'AxoDropdownMenu'; @@ -88,18 +90,41 @@ export namespace AxoDropdownMenu { 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 * against the trigger. */ export const Trigger: FC = memo(props => { + const ref = useRef(null); + + useEffect(() => { + if (process.env.NODE_ENV === 'development') { + assert( + ref.current instanceof HTMLElement, + `${triggerDisplayName} child must forward ref` + ); + assert( + getRole(ref.current) === 'button', + `${triggerDisplayName} child must be a