diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 9ff9e02c53..7b123d6cc4 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -4856,8 +4856,9 @@ button.module-calling-participants-list__contact { .module-conversation-list { $normal-row-height: 72px; - padding-inline-start: 10px; - padding-inline-end: 1px; /* leaving room for scrollbar */ + scrollbar-gutter: stable; + padding-inline-start: 11px; + padding-inline-end: calc(11px - var(--axo-scrollbar-gutter-thin-vertical)); @include mixins.scrollbar-on-hover; @@ -4867,13 +4868,6 @@ button.module-calling-participants-list__contact { padding-inline: 0; } - // Center chat list icons in narrow mode by reserving scrollbar space, preventing - // scrollbar from pushing content - &--width-narrow { - padding-inline: 10px 1px; - scrollbar-gutter: stable; - } - &--has-dialog-padding { padding-block-start: 8px; } diff --git a/stylesheets/components/CallsTab.scss b/stylesheets/components/CallsTab.scss index 0ca91d1742..cb61293760 100644 --- a/stylesheets/components/CallsTab.scss +++ b/stylesheets/components/CallsTab.scss @@ -209,6 +209,9 @@ } .CallsList__List { + scrollbar-gutter: stable; + padding-inline-start: 11px; + padding-inline-end: calc(11px - var(--axo-scrollbar-gutter-thin-vertical)); @include mixins.scrollbar-on-hover; } @@ -310,6 +313,8 @@ // Override .ListTile .ListTile.CallsList__ItemTile { padding-block: 10px; + border: none; + border-radius: 12px; // Override .ListTile__subtitle with correct font size .ListTile__subtitle { diff --git a/stylesheets/components/Preferences.scss b/stylesheets/components/Preferences.scss index caf01ac3a7..8be7e1e65b 100644 --- a/stylesheets/components/Preferences.scss +++ b/stylesheets/components/Preferences.scss @@ -54,6 +54,12 @@ $secondary-text-color: light-dark( &__scroll-area { overflow-y: scroll; max-height: 100%; + + scrollbar-gutter: stable; + padding-inline-start: 11px; + padding-inline-end: calc(11px - var(--axo-scrollbar-gutter-thin-vertical)); + + @include mixins.scrollbar-on-hover; } &__padding { @@ -71,10 +77,6 @@ $secondary-text-color: light-dark( flex-direction: row; align-items: center; - width: calc(100% - 11px); - margin-inline-start: 10px; - margin-inline-end: 1px; - margin-bottom: 4px; border-radius: 8px; @@ -212,12 +214,10 @@ $secondary-text-color: light-dark( @include mixins.font-body-1; align-items: center; display: flex; + width: 100%; height: 40px; - width: calc(100% - 11px); padding-block: 14px; padding-inline: 0; - margin-inline-start: 10px; - margin-inline-end: 1px; border-radius: 10px; margin-bottom: 4px; } diff --git a/stylesheets/components/Stories.scss b/stylesheets/components/Stories.scss index 9829639931..1ed02cf81f 100644 --- a/stylesheets/components/Stories.scss +++ b/stylesheets/components/Stories.scss @@ -105,8 +105,11 @@ flex-direction: column; flex: 1; overflow-y: auto; - padding-inline: 16px; - + scrollbar-gutter: stable; + padding-inline-start: 11px; + padding-inline-end: calc( + 11px - var(--axo-scrollbar-gutter-thin-vertical) + ); @include mixins.scrollbar-on-hover; &--empty { diff --git a/ts/axo/AxoDialog.dom.stories.tsx b/ts/axo/AxoDialog.dom.stories.tsx index eb0af60b06..35c0ecc1fc 100644 --- a/ts/axo/AxoDialog.dom.stories.tsx +++ b/ts/axo/AxoDialog.dom.stories.tsx @@ -1,12 +1,13 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ReactNode } from 'react'; -import React, { useState } 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'; export default { title: 'Axo/AxoDialog', @@ -143,3 +144,225 @@ 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(); + + 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..dd882dc0ff 100644 --- a/ts/axo/AxoDialog.dom.tsx +++ b/ts/axo/AxoDialog.dom.tsx @@ -14,36 +14,12 @@ import { AxoBaseDialog } from './_internal/AxoBaseDialog.dom.js'; import { AxoSymbol } from './AxoSymbol.dom.js'; import { tw } from './tw.dom.js'; import { AxoScrollArea } from './AxoScrollArea.dom.js'; -import { getScrollbarGutters } from './_internal/scrollbars.dom.js'; import { AxoButton } from './AxoButton.dom.js'; const Namespace = 'AxoDialog'; const { useContentEscapeBehavior, useContentSize } = AxoBaseDialog; -// We want to have 25px 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; - -const DIALOG_PADDING_TARGET = 20; - -const DIALOG_PADDING_BEFORE_SCROLLBAR_WIDTH = - DIALOG_PADDING_TARGET - SCROLLBAR_WIDTH_EXPECTED; - -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; - export namespace AxoDialog { /** * Component: @@ -126,19 +102,12 @@ export namespace AxoDialog { }>; export const Header: FC = memo(props => { - const style = useMemo(() => { - return { - paddingBlock: DIALOG_HEADER_PADDING_BLOCK, - paddingInline: DIALOG_PADDING_PLUS_SCROLLBAR_WIDTH, - }; - }, []); return (
{props.children}
@@ -193,7 +162,7 @@ export namespace AxoDialog { return ( ; 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 +217,16 @@ export namespace AxoDialog { Close.displayName = `${Namespace}.Close`; + export type ExperimentalSearchProps = Readonly<{ + children: ReactNode; + }>; + + export const ExperimentalSearch: FC = memo(props => { + return
{props.children}
; + }); + + ExperimentalSearch.displayName = `${Namespace}.ExperimentalSearch`; + /** * Component: * --------------------------- @@ -271,12 +244,13 @@ export namespace AxoDialog { const contentSize = useContentSize(); const contentSizeConfig = AxoBaseDialog.ContentSizes[contentSize]; - const style = useMemo((): CSSProperties => { + const style = useMemo((): CSSProperties | undefined => { + if (padding === 'only-scrollbar-gutter') { + return; + } + return { - paddingInline: - padding === 'normal' - ? DIALOG_PADDING_BEFORE_SCROLLBAR_WIDTH - : undefined, + paddingInline: 'calc(24px - var(--axo-scrollbar-gutter-thin-vertical))', }; }, [padding]); @@ -323,17 +297,8 @@ export namespace AxoDialog { }>; export const Footer: FC = memo(props => { - const style = useMemo((): CSSProperties => { - return { - paddingInline: DIALOG_PADDING_PLUS_SCROLLBAR_WIDTH, - }; - }, []); - return ( -
+
{props.children}
); @@ -354,6 +319,7 @@ export namespace AxoDialog { return (
; +let runOnceGlobally = false; + export const AxoProvider: FC = memo(props => { + useInsertionEffect(() => { + if (runOnceGlobally) { + return; + } + runOnceGlobally = true; + + const unsubscribe = createScrollbarGutterCssProperties(); + + return () => { + unsubscribe(); + runOnceGlobally = false; + }; + }); return ( {props.children} ); diff --git a/ts/axo/AxoScrollArea.dom.stories.tsx b/ts/axo/AxoScrollArea.dom.stories.tsx index 7ce9a6aa8f..f267d7b8b1 100644 --- a/ts/axo/AxoScrollArea.dom.stories.tsx +++ b/ts/axo/AxoScrollArea.dom.stories.tsx @@ -1,11 +1,10 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ReactNode } from 'react'; -import React, { useMemo } from 'react'; +import React from 'react'; import type { Meta } from '@storybook/react'; import { AxoScrollArea } from './AxoScrollArea.dom.js'; import { tw } from './tw.dom.js'; -import { getScrollbarGutters } from './_internal/scrollbars.dom.js'; import { AxoSymbol } from './AxoSymbol.dom.js'; export default { @@ -39,18 +38,9 @@ function VerticalTemplate(props: { hints?: boolean; mask?: boolean; }) { - const paddingInline = useMemo(() => { - return getScrollbarGutters('thin', 'custom').vertical; - }, []); - return (
-

- Header -

+

Header

-

- Footer -

+

Footer

); } diff --git a/ts/axo/AxoScrollArea.dom.tsx b/ts/axo/AxoScrollArea.dom.tsx index 4614961cdc..263eeed11e 100644 --- a/ts/axo/AxoScrollArea.dom.tsx +++ b/ts/axo/AxoScrollArea.dom.tsx @@ -5,7 +5,6 @@ import type { CSSProperties, FC, ReactNode } from 'react'; import type { TailwindStyles } from './tw.dom.js'; import { tw } from './tw.dom.js'; import { assert } from './_internal/assert.dom.js'; -import { getScrollbarGutters } from './_internal/scrollbars.dom.js'; const Namespace = 'AxoScrollArea'; @@ -182,6 +181,23 @@ export namespace AxoScrollArea { smooth: tw('scroll-smooth'), }; + type GutterCss = { horizontal: string; vertical: string }; + + const ScrollbarWidthToGutterCss: Record = { + 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', + }, + }; + export type ViewportProps = Readonly<{ children: ReactNode; }>; @@ -198,15 +214,15 @@ export namespace AxoScrollArea { // `scrollbar-gutter: stable both-edges` is broken in Chrome // See: https://issues.chromium.org/issues/40064879) // Instead we use padding to polyfill the feature - let paddingTop: number | undefined; - let paddingInlineStart: number | undefined; + let paddingTop: string | undefined; + let paddingInlineStart: string | undefined; if (scrollbarGutter === 'stable-both-edges') { - const scrollbarGutters = getScrollbarGutters(scrollbarWidth, 'custom'); if (hasVerticalScrollbar) { - paddingInlineStart = scrollbarGutters.vertical; + paddingInlineStart = + ScrollbarWidthToGutterCss[scrollbarWidth].vertical; } if (hasHorizontalScrollbar) { - paddingTop = scrollbarGutters.horizontal; + paddingTop = ScrollbarWidthToGutterCss[scrollbarWidth].horizontal; } } @@ -346,19 +362,17 @@ export namespace AxoScrollArea { const { scrollbarWidth } = useAxoScrollAreaConfig(); const style = useMemo((): CSSProperties => { - const scrollbarGutters = getScrollbarGutters(scrollbarWidth, 'custom'); - const isVerticalEdge = edge === 'top' || edge === 'bottom'; const isStartEdge = edge === 'top' || edge === 'inline-start'; return { insetInlineEnd: edge !== 'inline-start' && orientation === 'both' - ? scrollbarGutters.horizontal + ? ScrollbarWidthToGutterCss[scrollbarWidth].horizontal : undefined, bottom: edge !== 'top' && orientation === 'both' - ? scrollbarGutters.vertical + ? ScrollbarWidthToGutterCss[scrollbarWidth].vertical : undefined, animationTimeline: isVerticalEdge ? AXO_SCROLL_AREA_TIMELINE_VERTICAL @@ -417,16 +431,14 @@ export namespace AxoScrollArea { const { scrollbarWidth } = useAxoScrollAreaConfig(); const style = useMemo(() => { - const scrollbarGutters = getScrollbarGutters(scrollbarWidth, 'custom'); - const hasVerticalScrollbar = orientation !== 'horizontal'; const hasHorizontalScrollbar = orientation !== 'vertical'; const verticalGutter = hasVerticalScrollbar - ? `${scrollbarGutters.vertical}px` + ? ScrollbarWidthToGutterCss[scrollbarWidth].vertical : '0px'; const horizontalGutter = hasHorizontalScrollbar - ? `${scrollbarGutters.horizontal}px` + ? ScrollbarWidthToGutterCss[scrollbarWidth].horizontal : '0px'; return { diff --git a/ts/axo/_internal/scrollbars.dom.tsx b/ts/axo/_internal/scrollbars.dom.tsx index 898f6a3cfd..9468b5ef97 100644 --- a/ts/axo/_internal/scrollbars.dom.tsx +++ b/ts/axo/_internal/scrollbars.dom.tsx @@ -1,84 +1,152 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only - import { assert } from './assert.dom.js'; -export type ScrollbarWidth = 'wide' | 'thin' | 'none'; -export type ScrollbarColor = 'native' | 'custom'; - -const ScrollbarWidths: Record = { - wide: 'auto', - thin: 'thin', - none: 'none', -}; - -const ScrollbarColors: Record = { - native: 'auto', - custom: 'black transparent', -}; +export type ScrollbarWidth = 'auto' | 'thin' | 'none'; export type ScrollbarGutters = Readonly<{ vertical: number; horizontal: number; }>; -const SCROLLBAR_GUTTERS_CACHE = new Map(); - function isValidClientSize(value: number): boolean { return Number.isInteger(value) && value > 0; } -export function getScrollbarGutters( - scrollbarWidth: ScrollbarWidth, - scrollbarColor: ScrollbarColor -): ScrollbarGutters { - const cacheKey = `${scrollbarWidth}, ${scrollbarColor}`; - const cached = SCROLLBAR_GUTTERS_CACHE.get(cacheKey); - if (cached != null) { - return cached; +type Listener = () => void; +type Unsubscribe = () => void; + +class ScrollbarGuttersObserver { + #scroller: HTMLDivElement; + #current: ScrollbarGutters; + #observer: ResizeObserver; + #listeners = new Set(); + + constructor(scrollbarWidth: Exclude) { + const container = document.createElement('div'); + container.dataset.scrollbarGuttersObserver = scrollbarWidth; + + // Insert the element into the DOM to get non-zero measurements + document.body.append(container); + + const scroller = document.createElement('div'); + const content = document.createElement('div'); + + // Use `all: initial` to avoid other styles affecting the measurement + // This resets elements to their initial value (such as `display: inline`) + scroller.style.setProperty('all', 'initial'); + scroller.style.setProperty('position', 'absolute'); + scroller.style.setProperty('top', '-9999px'); + scroller.style.setProperty('left', '-9999px'); + scroller.style.setProperty('display', 'block'); + scroller.style.setProperty('visibility', 'hidden'); + scroller.style.setProperty('overflow', 'auto'); + scroller.style.setProperty('width', '100px'); + scroller.style.setProperty('height', '100px'); + scroller.style.setProperty('scrollbar-width', scrollbarWidth); + scroller.style.setProperty('scrollbar-color', 'black transparent'); + + content.style.setProperty('all', 'initial'); + content.style.setProperty('display', 'block'); + content.style.setProperty('width', '101px'); + content.style.setProperty('height', '101px'); + + scroller.append(content); + container.append(scroller); + + this.#scroller = scroller; + this.#current = this.#compute(); + this.#observer = new ResizeObserver(() => this.#update()); + this.#observer.observe(this.#scroller, { box: 'content-box' }); } - const outer = document.createElement('div'); - const inner = document.createElement('div'); + #compute(): ScrollbarGutters { + const { offsetWidth, offsetHeight, clientWidth, clientHeight } = + this.#scroller; - // Use `all: initial` to avoid other styles affecting the measurement - // This resets elements to their initial value (such as `display: inline`) - outer.style.setProperty('all', 'initial'); - outer.style.setProperty('display', 'block'); - outer.style.setProperty('visibility', 'hidden'); - outer.style.setProperty('overflow', 'auto'); - outer.style.setProperty('width', '100px'); - outer.style.setProperty('height', '100px'); - outer.style.setProperty('scrollbar-width', ScrollbarWidths[scrollbarWidth]); - outer.style.setProperty('scrollbar-color', ScrollbarColors[scrollbarColor]); + assert(offsetWidth === 100, 'offsetWidth must be exactly 100px'); + assert(offsetHeight === 100, 'offsetHeight must be exactly 100px'); + assert( + isValidClientSize(clientWidth), + 'clientWidth must be non-zero positive integer' + ); + assert( + isValidClientSize(clientHeight), + 'clientHeight must be non-zero positive integer' + ); - inner.style.setProperty('all', 'initial'); - inner.style.setProperty('display', 'block'); - inner.style.setProperty('width', '101px'); - inner.style.setProperty('height', '101px'); + const vertical = offsetWidth - clientWidth; + const horizontal = offsetHeight - clientHeight; - outer.append(inner); + return { vertical, horizontal }; + } - // Insert the element into the DOM to get non-zero measurements - document.body.append(outer); - const { offsetWidth, offsetHeight, clientWidth, clientHeight } = outer; - outer.remove(); + #update() { + const next = this.#compute(); - assert(offsetWidth === 100, 'offsetWidth must be exactly 100px'); - assert(offsetHeight === 100, 'offsetHeight must be exactly 100px'); - assert( - isValidClientSize(clientWidth), - 'clientWidth must be non-zero positive integer' - ); - assert( - isValidClientSize(clientHeight), - 'clientHeight must be non-zero positive integer' - ); + if ( + next.vertical === this.#current.vertical && + next.horizontal === this.#current.horizontal + ) { + return; + } - const vertical = offsetWidth - clientWidth; - const horizontal = offsetHeight - clientHeight; + this.#current = next; - const result: ScrollbarGutters = { vertical, horizontal }; - SCROLLBAR_GUTTERS_CACHE.set(cacheKey, result); - return result; + this.#listeners.forEach(listener => { + listener(); + }); + } + + current(): ScrollbarGutters { + return this.#current; + } + + subscribe(listener: Listener): Unsubscribe { + this.#listeners.add(listener); + return () => { + this.#listeners.delete(listener); + }; + } +} + +function applyGlobalProperties( + observer: ScrollbarGuttersObserver, + verticalProperty: `--${string}`, + horizontalProperty: `--${string}` +): Unsubscribe { + const root = document.documentElement; + + function update() { + const value = observer.current(); + root.style.setProperty(verticalProperty, `${value.vertical}px`); + root.style.setProperty(horizontalProperty, `${value.horizontal}px`); + } + + update(); + const unsubscribe = observer.subscribe(update); + return () => { + unsubscribe(); + root.style.removeProperty(verticalProperty); + root.style.removeProperty(horizontalProperty); + }; +} + +export function createScrollbarGutterCssProperties(): Unsubscribe { + const autoUnsubscribe = applyGlobalProperties( + new ScrollbarGuttersObserver('auto'), + '--axo-scrollbar-gutter-auto-vertical', + '--axo-scrollbar-gutter-auto-horizontal' + ); + + const thinUnsubscribe = applyGlobalProperties( + new ScrollbarGuttersObserver('thin'), + '--axo-scrollbar-gutter-thin-vertical', + '--axo-scrollbar-gutter-thin-horizontal' + ); + + return () => { + autoUnsubscribe(); + thinUnsubscribe(); + }; }