Fix scrollbars for all macOS settings

This commit is contained in:
Jamie
2025-11-12 11:47:34 -08:00
committed by GitHub
parent ce2661f655
commit 317ab3b3b9
10 changed files with 440 additions and 165 deletions

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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 &amp; 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>
);
}

View File

@@ -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]',

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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 {

View File

@@ -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();
};
}