mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-20 02:08:57 +00:00
Fix scrollbars for all macOS settings
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
</Template>
|
||||
);
|
||||
}
|
||||
|
||||
function Spacer(props: { height: 8 | 12 }) {
|
||||
return <div style={{ height: props.height }} />;
|
||||
}
|
||||
|
||||
function TextInputField(props: { placeholder: string }) {
|
||||
const style = useMemo(() => {
|
||||
const bodyPadding = 24;
|
||||
const inputPadding = 16;
|
||||
|
||||
return { marginInline: inputPadding - bodyPadding };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={tw('py-1.5')} style={style}>
|
||||
<input
|
||||
placeholder={props.placeholder}
|
||||
className={tw(
|
||||
'w-full px-3 py-1.5',
|
||||
'border-[0.5px] border-border-primary shadow-elevation-0',
|
||||
'rounded-lg bg-fill-primary',
|
||||
'placeholder:text-label-placeholder',
|
||||
'forced-colors:border forced-colors:border-[ButtonBorder] forced-colors:bg-[ButtonFace] forced-colors:text-[ButtonText]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExampleNicknameAndNoteDialog(): JSX.Element {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<AxoDialog.Root open={open} onOpenChange={setOpen}>
|
||||
<AxoDialog.Trigger>
|
||||
<AxoButton.Root variant="secondary" size="medium">
|
||||
Open Dialog
|
||||
</AxoButton.Root>
|
||||
</AxoDialog.Trigger>
|
||||
<AxoDialog.Content size="sm" escape="cancel-is-destructive">
|
||||
<AxoDialog.Header>
|
||||
<AxoDialog.Title>Nickname</AxoDialog.Title>
|
||||
<AxoDialog.Close aria-label="Close" />
|
||||
</AxoDialog.Header>
|
||||
<AxoDialog.Body>
|
||||
<p className={tw('mb-4 type-body-small text-label-secondary')}>
|
||||
Nicknames & notes are stored with Signal and end-to-end
|
||||
encrypted. They are only visible to you.
|
||||
</p>
|
||||
<div
|
||||
className={tw(
|
||||
'mx-auto size-20 rounded-full bg-color-fill-primary',
|
||||
'forced-colors:border'
|
||||
)}
|
||||
/>
|
||||
<Spacer height={12} />
|
||||
<TextInputField placeholder="First name" />
|
||||
<TextInputField placeholder="Last name" />
|
||||
<TextInputField placeholder="Note" />
|
||||
<Spacer height={12} />
|
||||
</AxoDialog.Body>
|
||||
<AxoDialog.Footer>
|
||||
<AxoDialog.Actions>
|
||||
<AxoDialog.Action variant="secondary" onClick={action('onCancel')}>
|
||||
Cancel
|
||||
</AxoDialog.Action>
|
||||
<AxoDialog.Action variant="primary" onClick={action('onSave')}>
|
||||
Save
|
||||
</AxoDialog.Action>
|
||||
</AxoDialog.Actions>
|
||||
</AxoDialog.Footer>
|
||||
</AxoDialog.Content>
|
||||
</AxoDialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckboxField(props: { label: string }) {
|
||||
const id = useId();
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={tw('flex gap-3 py-2.5')}>
|
||||
<AxoCheckbox.Root
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={setChecked}
|
||||
/>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={tw('truncate type-body-large text-label-primary')}
|
||||
>
|
||||
{props.label}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExampleMuteNotificationsDialog(): JSX.Element {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<AxoDialog.Root open={open} onOpenChange={setOpen}>
|
||||
<AxoDialog.Trigger>
|
||||
<AxoButton.Root variant="secondary" size="medium">
|
||||
Open Dialog
|
||||
</AxoButton.Root>
|
||||
</AxoDialog.Trigger>
|
||||
<AxoDialog.Content size="sm" escape="cancel-is-noop">
|
||||
<AxoDialog.Header>
|
||||
<AxoDialog.Title>Mute notifications</AxoDialog.Title>
|
||||
<AxoDialog.Close aria-label="Close" />
|
||||
</AxoDialog.Header>
|
||||
<AxoDialog.Body>
|
||||
<Spacer height={8} />
|
||||
<CheckboxField label="Mute for 1 hour" />
|
||||
<CheckboxField label="Mute for 8 hours" />
|
||||
<CheckboxField label="Mute for 1 day" />
|
||||
<CheckboxField label="Mute for 1 week" />
|
||||
<CheckboxField label="Mute always" />
|
||||
<Spacer height={8} />
|
||||
</AxoDialog.Body>
|
||||
<AxoDialog.Footer>
|
||||
<AxoDialog.Actions>
|
||||
<AxoDialog.Action variant="secondary" onClick={action('onCancel')}>
|
||||
Cancel
|
||||
</AxoDialog.Action>
|
||||
<AxoDialog.Action variant="primary" onClick={action('onSave')}>
|
||||
Save
|
||||
</AxoDialog.Action>
|
||||
</AxoDialog.Actions>
|
||||
</AxoDialog.Footer>
|
||||
</AxoDialog.Content>
|
||||
</AxoDialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ExampleItem(props: { label: string; description: string }) {
|
||||
const labelId = useId();
|
||||
const descriptionId = useId();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={descriptionId}
|
||||
tabIndex={0}
|
||||
className={tw('rounded-lg px-[13px] py-2.5 hover:bg-fill-secondary')}
|
||||
>
|
||||
<div
|
||||
id={labelId}
|
||||
className={tw('truncate type-body-large text-label-primary')}
|
||||
>
|
||||
{props.label}
|
||||
</div>
|
||||
<div
|
||||
id={descriptionId}
|
||||
className={tw('truncate type-body-small text-label-secondary')}
|
||||
>
|
||||
{props.description}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExampleLanguageDialog(): JSX.Element {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<AxoDialog.Root open={open} onOpenChange={setOpen}>
|
||||
<AxoDialog.Trigger>
|
||||
<AxoButton.Root variant="secondary" size="medium">
|
||||
Open Dialog
|
||||
</AxoButton.Root>
|
||||
</AxoDialog.Trigger>
|
||||
<AxoDialog.Content size="sm" escape="cancel-is-noop">
|
||||
<AxoDialog.Header>
|
||||
<AxoDialog.Title>Language</AxoDialog.Title>
|
||||
<AxoDialog.Close aria-label="Close" />
|
||||
</AxoDialog.Header>
|
||||
<AxoDialog.ExperimentalSearch>
|
||||
<input
|
||||
type="search"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
placeholder="Search languages"
|
||||
className={tw(
|
||||
'w-full rounded-lg bg-fill-secondary px-3 py-[5px]',
|
||||
'forced-colors:border forced-colors:border-[ButtonBorder] forced-colors:bg-[ButtonFace] forced-colors:text-[ButtonText]'
|
||||
)}
|
||||
/>
|
||||
</AxoDialog.ExperimentalSearch>
|
||||
<AxoDialog.Body padding="only-scrollbar-gutter">
|
||||
<div
|
||||
role="listbox"
|
||||
style={{
|
||||
paddingInline:
|
||||
'calc(11px - var(--axo-scrollbar-gutter-thin-vertical)',
|
||||
}}
|
||||
>
|
||||
<ExampleItem label="System Language" description="English" />
|
||||
<ExampleItem label="Afrikaans" description="Afrikaans" />
|
||||
<ExampleItem label="Arabic" description="العربية" />
|
||||
<ExampleItem label="Azerbaijani" description="Azərbaycan dili" />
|
||||
<ExampleItem label="Bulgarian" description="Български" />
|
||||
<ExampleItem label="Bangla" description="বাংলা" />
|
||||
<ExampleItem label="Bosnian" description="bosanski" />
|
||||
<ExampleItem label="Catalan" description="català" />
|
||||
<ExampleItem label="Czech" description="Čeština" />
|
||||
</div>
|
||||
</AxoDialog.Body>
|
||||
<AxoDialog.Footer>
|
||||
<AxoDialog.Actions>
|
||||
<AxoDialog.Action variant="secondary" onClick={action('onCancel')}>
|
||||
Cancel
|
||||
</AxoDialog.Action>
|
||||
<AxoDialog.Action variant="primary" onClick={action('onSet')}>
|
||||
Set
|
||||
</AxoDialog.Action>
|
||||
</AxoDialog.Actions>
|
||||
</AxoDialog.Footer>
|
||||
</AxoDialog.Content>
|
||||
</AxoDialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: <AxoDialog.Root>
|
||||
@@ -126,19 +102,12 @@ export namespace AxoDialog {
|
||||
}>;
|
||||
|
||||
export const Header: FC<HeaderProps> = memo(props => {
|
||||
const style = useMemo(() => {
|
||||
return {
|
||||
paddingBlock: DIALOG_HEADER_PADDING_BLOCK,
|
||||
paddingInline: DIALOG_PADDING_PLUS_SCROLLBAR_WIDTH,
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<div
|
||||
className={tw(
|
||||
'grid items-center',
|
||||
'grid items-center p-2.5',
|
||||
'grid-cols-[[back-slot]_1fr_[title-slot]_auto_[close-slot]_1fr]'
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
@@ -193,7 +162,7 @@ export namespace AxoDialog {
|
||||
return (
|
||||
<Dialog.Title
|
||||
className={tw(
|
||||
'col-[title-slot]',
|
||||
'col-[title-slot] px-3.5 py-0.5',
|
||||
'truncate text-center',
|
||||
'type-title-small text-label-primary'
|
||||
)}
|
||||
@@ -215,11 +184,8 @@ export namespace AxoDialog {
|
||||
}>;
|
||||
|
||||
export const Back: FC<BackProps> = memo(props => {
|
||||
const style = useMemo((): CSSProperties => {
|
||||
return { marginInlineStart: DIALOG_HEADER_ICON_BUTTON_MARGIN };
|
||||
}, []);
|
||||
return (
|
||||
<div className={tw('col-[back-slot] text-start')} style={style}>
|
||||
<div className={tw('col-[back-slot] text-start')}>
|
||||
<HeaderIconButton
|
||||
label={props['aria-label']}
|
||||
symbol="chevron-[start]"
|
||||
@@ -240,11 +206,8 @@ export namespace AxoDialog {
|
||||
}>;
|
||||
|
||||
export const Close: FC<CloseProps> = memo(props => {
|
||||
const style = useMemo((): CSSProperties => {
|
||||
return { marginInlineEnd: DIALOG_HEADER_ICON_BUTTON_MARGIN };
|
||||
}, []);
|
||||
return (
|
||||
<div className={tw('col-[close-slot] text-end')} style={style}>
|
||||
<div className={tw('col-[close-slot] text-end')}>
|
||||
<Dialog.Close asChild>
|
||||
<HeaderIconButton label={props['aria-label']} symbol="x" />
|
||||
</Dialog.Close>
|
||||
@@ -254,6 +217,16 @@ export namespace AxoDialog {
|
||||
|
||||
Close.displayName = `${Namespace}.Close`;
|
||||
|
||||
export type ExperimentalSearchProps = Readonly<{
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
export const ExperimentalSearch: FC<ExperimentalSearchProps> = memo(props => {
|
||||
return <div className={tw('px-4 pb-2')}>{props.children}</div>;
|
||||
});
|
||||
|
||||
ExperimentalSearch.displayName = `${Namespace}.ExperimentalSearch`;
|
||||
|
||||
/**
|
||||
* Component: <AxoDialog.Body>
|
||||
* ---------------------------
|
||||
@@ -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<FooterProps> = memo(props => {
|
||||
const style = useMemo((): CSSProperties => {
|
||||
return {
|
||||
paddingInline: DIALOG_PADDING_PLUS_SCROLLBAR_WIDTH,
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tw('flex flex-wrap items-center gap-3 py-3')}
|
||||
style={style}
|
||||
>
|
||||
<div className={tw('flex flex-wrap items-center gap-3 px-3 py-2.5')}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
@@ -354,6 +319,7 @@ export namespace AxoDialog {
|
||||
return (
|
||||
<div
|
||||
className={tw(
|
||||
'px-3',
|
||||
// Allow the flex layout to place it in the same row as the actions
|
||||
// if it can be wrapped to fit within the available space:
|
||||
'basis-[min-content]',
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import React, { memo, useInsertionEffect } from 'react';
|
||||
import { Direction } from 'radix-ui';
|
||||
import { createScrollbarGutterCssProperties } from './_internal/scrollbars.dom.js';
|
||||
|
||||
type AxoProviderProps = Readonly<{
|
||||
dir: 'ltr' | 'rtl';
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
let runOnceGlobally = false;
|
||||
|
||||
export const AxoProvider: FC<AxoProviderProps> = memo(props => {
|
||||
useInsertionEffect(() => {
|
||||
if (runOnceGlobally) {
|
||||
return;
|
||||
}
|
||||
runOnceGlobally = true;
|
||||
|
||||
const unsubscribe = createScrollbarGutterCssProperties();
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
runOnceGlobally = false;
|
||||
};
|
||||
});
|
||||
return (
|
||||
<Direction.Provider dir={props.dir}>{props.children}</Direction.Provider>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className={tw('w-64 rounded-2xl bg-background-secondary')}>
|
||||
<h1
|
||||
className={tw('pt-3 pb-2 type-title-large')}
|
||||
style={{ paddingInline }}
|
||||
>
|
||||
Header
|
||||
</h1>
|
||||
<h1 className={tw('px-3 pt-3 pb-2 type-title-large')}>Header</h1>
|
||||
<div className={tw(props.fit || 'h-100')}>
|
||||
<AxoScrollArea.Root
|
||||
scrollbarWidth="thin"
|
||||
@@ -76,9 +66,7 @@ function VerticalTemplate(props: {
|
||||
</MaybeMask>
|
||||
</AxoScrollArea.Root>
|
||||
</div>
|
||||
<p className={tw('pt-2 pb-3 type-title-large')} style={{ paddingInline }}>
|
||||
Footer
|
||||
</p>
|
||||
<p className={tw('px-3 pt-2 pb-3 type-title-large')}>Footer</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<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',
|
||||
},
|
||||
};
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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<ScrollbarWidth, string> = {
|
||||
wide: 'auto',
|
||||
thin: 'thin',
|
||||
none: 'none',
|
||||
};
|
||||
|
||||
const ScrollbarColors: Record<ScrollbarColor, string> = {
|
||||
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<string, ScrollbarGutters>();
|
||||
|
||||
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<Listener>();
|
||||
|
||||
constructor(scrollbarWidth: Exclude<ScrollbarWidth, 'none'>) {
|
||||
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();
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user