You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/soma/components/scroll-area/scroll-area-provider.svelte.ts

478 lines
17 KiB

import { Provider, context, type WithRefOpts } from '$soma/provider';
soma: audit fixes — 84 issues across 25 components, Table complete Table component: - Complete state manager: sorting, filtering, pagination, selection, column pinning (sticky), column sizing, expandable rows (sub-rows + detail panels), column visibility - Provider renders <table> with translated aria-label - Sorting opt-in per column, consumer places sort buttons - filteredRowCount/totalRowCount for correct pagination count - Sub-row IDs hierarchical (parentId.subIndex), no collisions - Production-grade test page (Swiss Industrial design) - Full README with all features documented Critical fixes (10): - Toast: memory leaks (timer + hotkey listener cleanup via $effect) - Toast: restartTimer() method for promise-based toasts - ScrollArea: type='always' now works (check order fix) - ScrollArea: thumb click no longer triggers track jump - Tooltip: no crash without <Tooltip.Group> (ctx.getOr) - Combobox: click-outside-to-close fixed (isValidEvent) - Popover: stray 'n' character removed from popover.svelte - TreeView: ARIA ownership fixed (role=treeitem on Branch, not Control) - Splitter: documented required array spread for reactivity High fixes (14): - Checkbox: group disabled/name propagation + checked sync via $effect - Select: aria-labelledby on content, aria-disabled on items - Stepper: aria-controls + aria-labelledby cross-linking - TagsInput: highlightPrev returns to input, role on Control/Item - Editable: Preview role=button + aria-label - Pagination: translated aria-label on items - RadioGroup: removed unused Soma import Medium fixes (60): - registerContract() added to all 25 components - Dead config=getSoma() removed from 7 providers - Dead imports removed (Tooltip, Popover, Slider, Combobox) - Dialog: aria-modal conditional on modal prop - Slider: pointer capture release + onpointercancel - Popover: hover timeout cleanup, aria-modal support - Tabs: inactive content hidden + aria-hidden + tabindex=-1 - ToggleGroup: aria-orientation + roving tabindex fallback - Toolbar: data-disabled on Button - Stepper: Content tabindex - TreeView: RTL keyboard support (expand/collapse key swap) - NumberField: Enter key commit, clampOnBlur, allowMouseWheel, scrubber a11y - Toast: data-state on root, aria-live on viewport - ScrollArea: scrollbar aria-label + tabindex - Collapsible: part root (not provider), aria-controls, role=region - Tooltip: removed dead provider part from attrs Architecture: - COMPONENT_GUIDE.md: 14 audit-derived rules (A1-A14) - Checklist expanded from 15 to 23 steps - 8 new HTML primitive types (Table, THead, TBody, TFoot, TR, TH, TD, Caption) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6 months ago
import { createAttrs, registerContract, boolToEmptyStrOrUndef } from '$soma/attrs';
import { readableActive, state, type Active, type ActiveProps } from '$soma/reactive';
import type { Direction } from '$soma/types';
import { isBrowser } from '$soma/dom';
soma: audit fixes — 84 issues across 25 components, Table complete Table component: - Complete state manager: sorting, filtering, pagination, selection, column pinning (sticky), column sizing, expandable rows (sub-rows + detail panels), column visibility - Provider renders <table> with translated aria-label - Sorting opt-in per column, consumer places sort buttons - filteredRowCount/totalRowCount for correct pagination count - Sub-row IDs hierarchical (parentId.subIndex), no collisions - Production-grade test page (Swiss Industrial design) - Full README with all features documented Critical fixes (10): - Toast: memory leaks (timer + hotkey listener cleanup via $effect) - Toast: restartTimer() method for promise-based toasts - ScrollArea: type='always' now works (check order fix) - ScrollArea: thumb click no longer triggers track jump - Tooltip: no crash without <Tooltip.Group> (ctx.getOr) - Combobox: click-outside-to-close fixed (isValidEvent) - Popover: stray 'n' character removed from popover.svelte - TreeView: ARIA ownership fixed (role=treeitem on Branch, not Control) - Splitter: documented required array spread for reactivity High fixes (14): - Checkbox: group disabled/name propagation + checked sync via $effect - Select: aria-labelledby on content, aria-disabled on items - Stepper: aria-controls + aria-labelledby cross-linking - TagsInput: highlightPrev returns to input, role on Control/Item - Editable: Preview role=button + aria-label - Pagination: translated aria-label on items - RadioGroup: removed unused Soma import Medium fixes (60): - registerContract() added to all 25 components - Dead config=getSoma() removed from 7 providers - Dead imports removed (Tooltip, Popover, Slider, Combobox) - Dialog: aria-modal conditional on modal prop - Slider: pointer capture release + onpointercancel - Popover: hover timeout cleanup, aria-modal support - Tabs: inactive content hidden + aria-hidden + tabindex=-1 - ToggleGroup: aria-orientation + roving tabindex fallback - Toolbar: data-disabled on Button - Stepper: Content tabindex - TreeView: RTL keyboard support (expand/collapse key swap) - NumberField: Enter key commit, clampOnBlur, allowMouseWheel, scrubber a11y - Toast: data-state on root, aria-live on viewport - ScrollArea: scrollbar aria-label + tabindex - Collapsible: part root (not provider), aria-controls, role=region - Tooltip: removed dead provider part from attrs Architecture: - COMPONENT_GUIDE.md: 14 audit-derived rules (A1-A14) - Checklist expanded from 15 to 23 steps - 8 new HTML primitive types (Table, THead, TBody, TFoot, TR, TH, TD, Caption) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6 months ago
const attrs = createAttrs({
component: 'scroll-area',
parts: ['root', 'viewport', 'scrollbar', 'thumb', 'corner'] as const,
});
soma: audit fixes — 84 issues across 25 components, Table complete Table component: - Complete state manager: sorting, filtering, pagination, selection, column pinning (sticky), column sizing, expandable rows (sub-rows + detail panels), column visibility - Provider renders <table> with translated aria-label - Sorting opt-in per column, consumer places sort buttons - filteredRowCount/totalRowCount for correct pagination count - Sub-row IDs hierarchical (parentId.subIndex), no collisions - Production-grade test page (Swiss Industrial design) - Full README with all features documented Critical fixes (10): - Toast: memory leaks (timer + hotkey listener cleanup via $effect) - Toast: restartTimer() method for promise-based toasts - ScrollArea: type='always' now works (check order fix) - ScrollArea: thumb click no longer triggers track jump - Tooltip: no crash without <Tooltip.Group> (ctx.getOr) - Combobox: click-outside-to-close fixed (isValidEvent) - Popover: stray 'n' character removed from popover.svelte - TreeView: ARIA ownership fixed (role=treeitem on Branch, not Control) - Splitter: documented required array spread for reactivity High fixes (14): - Checkbox: group disabled/name propagation + checked sync via $effect - Select: aria-labelledby on content, aria-disabled on items - Stepper: aria-controls + aria-labelledby cross-linking - TagsInput: highlightPrev returns to input, role on Control/Item - Editable: Preview role=button + aria-label - Pagination: translated aria-label on items - RadioGroup: removed unused Soma import Medium fixes (60): - registerContract() added to all 25 components - Dead config=getSoma() removed from 7 providers - Dead imports removed (Tooltip, Popover, Slider, Combobox) - Dialog: aria-modal conditional on modal prop - Slider: pointer capture release + onpointercancel - Popover: hover timeout cleanup, aria-modal support - Tabs: inactive content hidden + aria-hidden + tabindex=-1 - ToggleGroup: aria-orientation + roving tabindex fallback - Toolbar: data-disabled on Button - Stepper: Content tabindex - TreeView: RTL keyboard support (expand/collapse key swap) - NumberField: Enter key commit, clampOnBlur, allowMouseWheel, scrubber a11y - Toast: data-state on root, aria-live on viewport - ScrollArea: scrollbar aria-label + tabindex - Collapsible: part root (not provider), aria-controls, role=region - Tooltip: removed dead provider part from attrs Architecture: - COMPONENT_GUIDE.md: 14 audit-derived rules (A1-A14) - Checklist expanded from 15 to 23 steps - 8 new HTML primitive types (Table, THead, TBody, TFoot, TR, TH, TD, Caption) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6 months ago
registerContract({ name: 'scroll-area', version: 1, parts: {
'root': [{ attr: 'data-overflow-x', description: 'Horizontal overflow' }, { attr: 'data-overflow-y', description: 'Vertical overflow' }],
'viewport': [{ attr: 'data-at-top', description: 'At top' }, { attr: 'data-at-bottom', description: 'At bottom' }, { attr: 'data-at-left', description: 'At left' }, { attr: 'data-at-right', description: 'At right' }],
'scrollbar': [{ attr: 'data-state', values: ['visible', 'hidden'], description: 'Visibility' }, { attr: 'data-orientation', values: ['horizontal', 'vertical'], description: 'Orientation' }, { attr: 'data-hover', description: 'Hover' }, { attr: 'data-dragging', description: 'Dragging' }],
'thumb': [{ attr: 'data-state', values: ['visible', 'hidden'], description: 'Visibility' }, { attr: 'data-orientation', values: ['horizontal', 'vertical'], description: 'Orientation' }],
'corner': [{ attr: 'data-state', values: ['visible', 'hidden'], description: 'Visibility' }],
}});
export type ScrollAreaType = 'hover' | 'scroll' | 'auto' | 'always';
// ── Root ─────────────────────────────────────────────────────────────────────
interface ScrollAreaOpts
extends WithRefOpts,
ActiveProps<{
type: ScrollAreaType;
scrollHideDelay: number;
dir: Direction;
}> {}
export class ScrollAreaProvider extends Provider<ScrollAreaOpts> {
static readonly ctx = context<ScrollAreaProvider>('ScrollArea');
static from(opts: ScrollAreaOpts) {
return new ScrollAreaProvider(opts);
}
// Viewport tracking
viewportRef = state<HTMLElement | null>(null);
contentWidth = $state(0);
contentHeight = $state(0);
viewportWidth = $state(0);
viewportHeight = $state(0);
scrollTop = $state(0);
scrollLeft = $state(0);
private resizeObserver: ResizeObserver | null = null;
private constructor(opts: ScrollAreaOpts) {
super(opts, 'ScrollArea', 'root', attrs.root, ScrollAreaProvider.ctx);
}
// ── Overflow detection ───────────────────────────────────────────────────
readonly hasOverflowX = $derived.by(() => this.contentWidth > this.viewportWidth);
readonly hasOverflowY = $derived.by(() => this.contentHeight > this.viewportHeight);
// ── Position tracking ────────────────────────────────────────────────────
readonly isAtTop = $derived.by(() => this.scrollTop <= 0);
readonly isAtBottom = $derived.by(
() => this.scrollTop >= this.contentHeight - this.viewportHeight - 1,
);
readonly isAtLeft = $derived.by(() => this.scrollLeft <= 0);
readonly isAtRight = $derived.by(
() => this.scrollLeft >= this.contentWidth - this.viewportWidth - 1,
);
// ── Scroll progress (0..1) ───────────────────────────────────────────────
getScrollProgressY(): number {
const max = this.contentHeight - this.viewportHeight;
return max <= 0 ? 0 : this.scrollTop / max;
}
getScrollProgressX(): number {
const max = this.contentWidth - this.viewportWidth;
return max <= 0 ? 0 : this.scrollLeft / max;
}
// ── Viewport setup ───────────────────────────────────────────────────────
setupViewport(el: HTMLElement | null) {
this.viewportRef.current = el;
this.cleanupResize();
if (!el || !isBrowser) return;
const update = () => {
this.viewportWidth = el.clientWidth;
this.viewportHeight = el.clientHeight;
this.contentWidth = el.scrollWidth;
this.contentHeight = el.scrollHeight;
};
update();
this.resizeObserver = new ResizeObserver(update);
this.resizeObserver.observe(el);
if (el.firstElementChild) {
this.resizeObserver.observe(el.firstElementChild);
}
}
handleScroll(el: HTMLElement) {
this.scrollTop = el.scrollTop;
this.scrollLeft = el.scrollLeft;
// Re-measure in case content changed
this.contentWidth = el.scrollWidth;
this.contentHeight = el.scrollHeight;
}
private cleanupResize() {
this.resizeObserver?.disconnect();
this.resizeObserver = null;
}
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
style: {
position: 'relative',
'--soma-scroll-area-corner-width':
this.hasOverflowX && this.hasOverflowY ? 'var(--soma-scroll-area-scrollbar-size, 8px)' : '0px',
'--soma-scroll-area-corner-height':
this.hasOverflowX && this.hasOverflowY ? 'var(--soma-scroll-area-scrollbar-size, 8px)' : '0px',
},
'data-overflow-x': boolToEmptyStrOrUndef(this.hasOverflowX),
'data-overflow-y': boolToEmptyStrOrUndef(this.hasOverflowY),
} as const),
);
}
// ── Viewport ────────────────────────────────────────────────────────────────
interface ScrollAreaViewportOpts extends WithRefOpts {}
export class ScrollAreaViewportProvider extends Provider<ScrollAreaViewportOpts> {
static from(opts: ScrollAreaViewportOpts) {
return new ScrollAreaViewportProvider(opts);
}
readonly provider: ScrollAreaProvider;
private constructor(opts: ScrollAreaViewportOpts) {
super(opts, 'ScrollArea', 'viewport', attrs.viewport, undefined, (el) => {
this.provider.setupViewport(el);
});
this.provider = ScrollAreaProvider.ctx.get();
}
readonly onscroll = (e: Event) => {
this.provider.handleScroll(e.currentTarget as HTMLElement);
};
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
style: {
overflow: 'scroll',
'scrollbar-width': 'none',
},
'data-at-top': boolToEmptyStrOrUndef(this.provider.isAtTop),
'data-at-bottom': boolToEmptyStrOrUndef(this.provider.isAtBottom),
'data-at-left': boolToEmptyStrOrUndef(this.provider.isAtLeft),
'data-at-right': boolToEmptyStrOrUndef(this.provider.isAtRight),
'data-overflow-x': boolToEmptyStrOrUndef(this.provider.hasOverflowX),
'data-overflow-y': boolToEmptyStrOrUndef(this.provider.hasOverflowY),
onscroll: this.onscroll,
} as const),
);
}
// ── Scrollbar ───────────────────────────────────────────────────────────────
type Orientation = 'horizontal' | 'vertical';
interface ScrollAreaScrollbarOpts
extends WithRefOpts,
ActiveProps<{
orientation: Orientation;
}> {}
export class ScrollAreaScrollbarProvider extends Provider<ScrollAreaScrollbarOpts> {
static readonly ctx = context<ScrollAreaScrollbarProvider>('ScrollAreaScrollbar');
static from(opts: ScrollAreaScrollbarOpts) {
return new ScrollAreaScrollbarProvider(opts);
}
readonly provider: ScrollAreaProvider;
// Visibility state
visible = $state(false);
private hideTimer: ReturnType<typeof setTimeout> | null = null;
hovering = $state(false);
dragging = $state(false);
private constructor(opts: ScrollAreaScrollbarOpts) {
super(opts, 'ScrollArea', 'scrollbar', attrs.scrollbar, ScrollAreaScrollbarProvider.ctx);
this.provider = ScrollAreaProvider.ctx.get();
}
private get isVertical(): boolean {
return this.opts.orientation.current === 'vertical';
}
private get hasOverflow(): boolean {
return this.isVertical ? this.provider.hasOverflowY : this.provider.hasOverflowX;
}
/** Whether the scrollbar should be displayed based on type and state. */
readonly shouldShow = $derived.by(() => {
const type = this.provider.opts.type.current;
if (type === 'always') return true;
soma: audit fixes — 84 issues across 25 components, Table complete Table component: - Complete state manager: sorting, filtering, pagination, selection, column pinning (sticky), column sizing, expandable rows (sub-rows + detail panels), column visibility - Provider renders <table> with translated aria-label - Sorting opt-in per column, consumer places sort buttons - filteredRowCount/totalRowCount for correct pagination count - Sub-row IDs hierarchical (parentId.subIndex), no collisions - Production-grade test page (Swiss Industrial design) - Full README with all features documented Critical fixes (10): - Toast: memory leaks (timer + hotkey listener cleanup via $effect) - Toast: restartTimer() method for promise-based toasts - ScrollArea: type='always' now works (check order fix) - ScrollArea: thumb click no longer triggers track jump - Tooltip: no crash without <Tooltip.Group> (ctx.getOr) - Combobox: click-outside-to-close fixed (isValidEvent) - Popover: stray 'n' character removed from popover.svelte - TreeView: ARIA ownership fixed (role=treeitem on Branch, not Control) - Splitter: documented required array spread for reactivity High fixes (14): - Checkbox: group disabled/name propagation + checked sync via $effect - Select: aria-labelledby on content, aria-disabled on items - Stepper: aria-controls + aria-labelledby cross-linking - TagsInput: highlightPrev returns to input, role on Control/Item - Editable: Preview role=button + aria-label - Pagination: translated aria-label on items - RadioGroup: removed unused Soma import Medium fixes (60): - registerContract() added to all 25 components - Dead config=getSoma() removed from 7 providers - Dead imports removed (Tooltip, Popover, Slider, Combobox) - Dialog: aria-modal conditional on modal prop - Slider: pointer capture release + onpointercancel - Popover: hover timeout cleanup, aria-modal support - Tabs: inactive content hidden + aria-hidden + tabindex=-1 - ToggleGroup: aria-orientation + roving tabindex fallback - Toolbar: data-disabled on Button - Stepper: Content tabindex - TreeView: RTL keyboard support (expand/collapse key swap) - NumberField: Enter key commit, clampOnBlur, allowMouseWheel, scrubber a11y - Toast: data-state on root, aria-live on viewport - ScrollArea: scrollbar aria-label + tabindex - Collapsible: part root (not provider), aria-controls, role=region - Tooltip: removed dead provider part from attrs Architecture: - COMPONENT_GUIDE.md: 14 audit-derived rules (A1-A14) - Checklist expanded from 15 to 23 steps - 8 new HTML primitive types (Table, THead, TBody, TFoot, TR, TH, TD, Caption) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6 months ago
if (!this.hasOverflow) return false;
if (type === 'auto') return true;
if (type === 'hover') return this.visible;
if (type === 'scroll') return this.visible;
return false;
});
/** Show the scrollbar (for hover/scroll modes). */
show() {
this.clearHideTimer();
this.visible = true;
}
/** Schedule hiding the scrollbar after delay. */
scheduleHide() {
this.clearHideTimer();
const delay = this.provider.opts.scrollHideDelay.current;
this.hideTimer = setTimeout(() => {
if (!this.hovering && !this.dragging) {
this.visible = false;
}
}, delay);
}
private clearHideTimer() {
if (this.hideTimer) {
clearTimeout(this.hideTimer);
this.hideTimer = null;
}
}
readonly onpointerenter = () => {
this.hovering = true;
this.show();
};
readonly onpointerleave = () => {
this.hovering = false;
if (!this.dragging) this.scheduleHide();
};
/** Handle click on the track (not thumb) to scroll to that position. */
readonly onclick = (e: MouseEvent) => {
const el = this.opts.ref.current;
const viewport = this.provider.viewportRef.current;
if (!el || !viewport) return;
const rect = el.getBoundingClientRect();
if (this.isVertical) {
const clickRatio = (e.clientY - rect.top) / rect.height;
const maxScroll = this.provider.contentHeight - this.provider.viewportHeight;
viewport.scrollTop = clickRatio * maxScroll;
} else {
const clickRatio = (e.clientX - rect.left) / rect.width;
const maxScroll = this.provider.contentWidth - this.provider.viewportWidth;
viewport.scrollLeft = clickRatio * maxScroll;
}
};
// ── Scroll value for ARIA ────────────────────────────────────────────────
private get scrollPercent(): number {
return Math.round(
(this.isVertical ? this.provider.getScrollProgressY() : this.provider.getScrollProgressX()) * 100,
);
}
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'scrollbar' as const,
'aria-controls': this.provider.viewportRef.current?.id || undefined,
'aria-valuenow': this.scrollPercent,
'aria-valuemin': 0,
'aria-valuemax': 100,
'aria-orientation': this.opts.orientation.current,
soma: audit fixes — 84 issues across 25 components, Table complete Table component: - Complete state manager: sorting, filtering, pagination, selection, column pinning (sticky), column sizing, expandable rows (sub-rows + detail panels), column visibility - Provider renders <table> with translated aria-label - Sorting opt-in per column, consumer places sort buttons - filteredRowCount/totalRowCount for correct pagination count - Sub-row IDs hierarchical (parentId.subIndex), no collisions - Production-grade test page (Swiss Industrial design) - Full README with all features documented Critical fixes (10): - Toast: memory leaks (timer + hotkey listener cleanup via $effect) - Toast: restartTimer() method for promise-based toasts - ScrollArea: type='always' now works (check order fix) - ScrollArea: thumb click no longer triggers track jump - Tooltip: no crash without <Tooltip.Group> (ctx.getOr) - Combobox: click-outside-to-close fixed (isValidEvent) - Popover: stray 'n' character removed from popover.svelte - TreeView: ARIA ownership fixed (role=treeitem on Branch, not Control) - Splitter: documented required array spread for reactivity High fixes (14): - Checkbox: group disabled/name propagation + checked sync via $effect - Select: aria-labelledby on content, aria-disabled on items - Stepper: aria-controls + aria-labelledby cross-linking - TagsInput: highlightPrev returns to input, role on Control/Item - Editable: Preview role=button + aria-label - Pagination: translated aria-label on items - RadioGroup: removed unused Soma import Medium fixes (60): - registerContract() added to all 25 components - Dead config=getSoma() removed from 7 providers - Dead imports removed (Tooltip, Popover, Slider, Combobox) - Dialog: aria-modal conditional on modal prop - Slider: pointer capture release + onpointercancel - Popover: hover timeout cleanup, aria-modal support - Tabs: inactive content hidden + aria-hidden + tabindex=-1 - ToggleGroup: aria-orientation + roving tabindex fallback - Toolbar: data-disabled on Button - Stepper: Content tabindex - TreeView: RTL keyboard support (expand/collapse key swap) - NumberField: Enter key commit, clampOnBlur, allowMouseWheel, scrubber a11y - Toast: data-state on root, aria-live on viewport - ScrollArea: scrollbar aria-label + tabindex - Collapsible: part root (not provider), aria-controls, role=region - Tooltip: removed dead provider part from attrs Architecture: - COMPONENT_GUIDE.md: 14 audit-derived rules (A1-A14) - Checklist expanded from 15 to 23 steps - 8 new HTML primitive types (Table, THead, TBody, TFoot, TR, TH, TD, Caption) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6 months ago
'aria-label': this.isVertical ? 'Vertical scrollbar' : 'Horizontal scrollbar',
tabindex: -1,
'data-state': this.shouldShow ? 'visible' : 'hidden',
'data-orientation': this.opts.orientation.current,
'data-hover': boolToEmptyStrOrUndef(this.hovering),
'data-dragging': boolToEmptyStrOrUndef(this.dragging),
'data-overflow-x': boolToEmptyStrOrUndef(this.provider.hasOverflowX),
'data-overflow-y': boolToEmptyStrOrUndef(this.provider.hasOverflowY),
onpointerenter: this.onpointerenter,
onpointerleave: this.onpointerleave,
onclick: this.onclick,
} as const),
);
}
// ── Thumb ────────────────────────────────────────────────────────────────────
interface ScrollAreaThumbOpts extends WithRefOpts {}
export class ScrollAreaThumbProvider extends Provider<ScrollAreaThumbOpts> {
static from(opts: ScrollAreaThumbOpts) {
return new ScrollAreaThumbProvider(opts);
}
readonly scrollbar: ScrollAreaScrollbarProvider;
readonly provider: ScrollAreaProvider;
private dragStartY = 0;
private dragStartX = 0;
private dragStartScrollTop = 0;
private dragStartScrollLeft = 0;
private constructor(opts: ScrollAreaThumbOpts) {
super(opts, 'ScrollArea', 'thumb', attrs.thumb);
this.scrollbar = ScrollAreaScrollbarProvider.ctx.get();
this.provider = this.scrollbar.provider;
}
private get isVertical(): boolean {
return this.scrollbar.opts.orientation.current === 'vertical';
}
// ── Size calculation ─────────────────────────────────────────────────────
private get thumbSize(): number {
if (this.isVertical) {
const ratio = this.provider.viewportHeight / this.provider.contentHeight;
const trackHeight = this.scrollbar.opts.ref.current?.clientHeight ?? 0;
return Math.max(ratio * trackHeight, 20);
} else {
const ratio = this.provider.viewportWidth / this.provider.contentWidth;
const trackWidth = this.scrollbar.opts.ref.current?.clientWidth ?? 0;
return Math.max(ratio * trackWidth, 20);
}
}
private get thumbOffset(): number {
const scrollbarEl = this.scrollbar.opts.ref.current;
if (!scrollbarEl) return 0;
if (this.isVertical) {
const trackHeight = scrollbarEl.clientHeight;
const maxOffset = trackHeight - this.thumbSize;
return this.provider.getScrollProgressY() * maxOffset;
} else {
const trackWidth = scrollbarEl.clientWidth;
const maxOffset = trackWidth - this.thumbSize;
return this.provider.getScrollProgressX() * maxOffset;
}
}
// ── Drag handling ────────────────────────────────────────────────────────
readonly onpointerdown = (e: PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const viewport = this.provider.viewportRef.current;
if (!viewport) return;
this.scrollbar.dragging = true;
this.dragStartY = e.clientY;
this.dragStartX = e.clientX;
this.dragStartScrollTop = viewport.scrollTop;
this.dragStartScrollLeft = viewport.scrollLeft;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
};
readonly onpointermove = (e: PointerEvent) => {
if (!this.scrollbar.dragging) return;
e.preventDefault();
const viewport = this.provider.viewportRef.current;
const scrollbarEl = this.scrollbar.opts.ref.current;
if (!viewport || !scrollbarEl) return;
if (this.isVertical) {
const deltaY = e.clientY - this.dragStartY;
const trackHeight = scrollbarEl.clientHeight;
const scrollRatio = deltaY / (trackHeight - this.thumbSize);
const maxScroll = this.provider.contentHeight - this.provider.viewportHeight;
viewport.scrollTop = this.dragStartScrollTop + scrollRatio * maxScroll;
} else {
const deltaX = e.clientX - this.dragStartX;
const trackWidth = scrollbarEl.clientWidth;
const scrollRatio = deltaX / (trackWidth - this.thumbSize);
const maxScroll = this.provider.contentWidth - this.provider.viewportWidth;
viewport.scrollLeft = this.dragStartScrollLeft + scrollRatio * maxScroll;
}
};
readonly onpointerup = (_e: PointerEvent) => {
this.scrollbar.dragging = false;
this.scrollbar.scheduleHide();
};
readonly onlostpointercapture = () => {
this.scrollbar.dragging = false;
};
soma: audit fixes — 84 issues across 25 components, Table complete Table component: - Complete state manager: sorting, filtering, pagination, selection, column pinning (sticky), column sizing, expandable rows (sub-rows + detail panels), column visibility - Provider renders <table> with translated aria-label - Sorting opt-in per column, consumer places sort buttons - filteredRowCount/totalRowCount for correct pagination count - Sub-row IDs hierarchical (parentId.subIndex), no collisions - Production-grade test page (Swiss Industrial design) - Full README with all features documented Critical fixes (10): - Toast: memory leaks (timer + hotkey listener cleanup via $effect) - Toast: restartTimer() method for promise-based toasts - ScrollArea: type='always' now works (check order fix) - ScrollArea: thumb click no longer triggers track jump - Tooltip: no crash without <Tooltip.Group> (ctx.getOr) - Combobox: click-outside-to-close fixed (isValidEvent) - Popover: stray 'n' character removed from popover.svelte - TreeView: ARIA ownership fixed (role=treeitem on Branch, not Control) - Splitter: documented required array spread for reactivity High fixes (14): - Checkbox: group disabled/name propagation + checked sync via $effect - Select: aria-labelledby on content, aria-disabled on items - Stepper: aria-controls + aria-labelledby cross-linking - TagsInput: highlightPrev returns to input, role on Control/Item - Editable: Preview role=button + aria-label - Pagination: translated aria-label on items - RadioGroup: removed unused Soma import Medium fixes (60): - registerContract() added to all 25 components - Dead config=getSoma() removed from 7 providers - Dead imports removed (Tooltip, Popover, Slider, Combobox) - Dialog: aria-modal conditional on modal prop - Slider: pointer capture release + onpointercancel - Popover: hover timeout cleanup, aria-modal support - Tabs: inactive content hidden + aria-hidden + tabindex=-1 - ToggleGroup: aria-orientation + roving tabindex fallback - Toolbar: data-disabled on Button - Stepper: Content tabindex - TreeView: RTL keyboard support (expand/collapse key swap) - NumberField: Enter key commit, clampOnBlur, allowMouseWheel, scrubber a11y - Toast: data-state on root, aria-live on viewport - ScrollArea: scrollbar aria-label + tabindex - Collapsible: part root (not provider), aria-controls, role=region - Tooltip: removed dead provider part from attrs Architecture: - COMPONENT_GUIDE.md: 14 audit-derived rules (A1-A14) - Checklist expanded from 15 to 23 steps - 8 new HTML primitive types (Table, THead, TBody, TFoot, TR, TH, TD, Caption) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6 months ago
readonly onclick = (e: MouseEvent) => {
e.stopPropagation();
};
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'data-state': this.scrollbar.shouldShow ? 'visible' : 'hidden',
'data-orientation': this.scrollbar.opts.orientation.current,
'data-hover': boolToEmptyStrOrUndef(this.scrollbar.hovering),
'data-dragging': boolToEmptyStrOrUndef(this.scrollbar.dragging),
style: {
position: 'absolute',
[this.isVertical ? 'top' : 'left']: `${this.thumbOffset}px`,
[this.isVertical ? 'height' : 'width']: `${this.thumbSize}px`,
[this.isVertical ? 'width' : 'height']: '100%',
'border-radius': 'inherit',
'--soma-scroll-area-thumb-width': `${this.isVertical ? '100%' : `${this.thumbSize}px`}`,
'--soma-scroll-area-thumb-height': `${this.isVertical ? `${this.thumbSize}px` : '100%'}`,
},
onpointerdown: this.onpointerdown,
onpointermove: this.onpointermove,
onpointerup: this.onpointerup,
onlostpointercapture: this.onlostpointercapture,
soma: audit fixes — 84 issues across 25 components, Table complete Table component: - Complete state manager: sorting, filtering, pagination, selection, column pinning (sticky), column sizing, expandable rows (sub-rows + detail panels), column visibility - Provider renders <table> with translated aria-label - Sorting opt-in per column, consumer places sort buttons - filteredRowCount/totalRowCount for correct pagination count - Sub-row IDs hierarchical (parentId.subIndex), no collisions - Production-grade test page (Swiss Industrial design) - Full README with all features documented Critical fixes (10): - Toast: memory leaks (timer + hotkey listener cleanup via $effect) - Toast: restartTimer() method for promise-based toasts - ScrollArea: type='always' now works (check order fix) - ScrollArea: thumb click no longer triggers track jump - Tooltip: no crash without <Tooltip.Group> (ctx.getOr) - Combobox: click-outside-to-close fixed (isValidEvent) - Popover: stray 'n' character removed from popover.svelte - TreeView: ARIA ownership fixed (role=treeitem on Branch, not Control) - Splitter: documented required array spread for reactivity High fixes (14): - Checkbox: group disabled/name propagation + checked sync via $effect - Select: aria-labelledby on content, aria-disabled on items - Stepper: aria-controls + aria-labelledby cross-linking - TagsInput: highlightPrev returns to input, role on Control/Item - Editable: Preview role=button + aria-label - Pagination: translated aria-label on items - RadioGroup: removed unused Soma import Medium fixes (60): - registerContract() added to all 25 components - Dead config=getSoma() removed from 7 providers - Dead imports removed (Tooltip, Popover, Slider, Combobox) - Dialog: aria-modal conditional on modal prop - Slider: pointer capture release + onpointercancel - Popover: hover timeout cleanup, aria-modal support - Tabs: inactive content hidden + aria-hidden + tabindex=-1 - ToggleGroup: aria-orientation + roving tabindex fallback - Toolbar: data-disabled on Button - Stepper: Content tabindex - TreeView: RTL keyboard support (expand/collapse key swap) - NumberField: Enter key commit, clampOnBlur, allowMouseWheel, scrubber a11y - Toast: data-state on root, aria-live on viewport - ScrollArea: scrollbar aria-label + tabindex - Collapsible: part root (not provider), aria-controls, role=region - Tooltip: removed dead provider part from attrs Architecture: - COMPONENT_GUIDE.md: 14 audit-derived rules (A1-A14) - Checklist expanded from 15 to 23 steps - 8 new HTML primitive types (Table, THead, TBody, TFoot, TR, TH, TD, Caption) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6 months ago
onclick: this.onclick,
} as const),
);
}
// ── Corner ──────────────────────────────────────────────────────────────────
interface ScrollAreaCornerOpts extends WithRefOpts {}
export class ScrollAreaCornerProvider extends Provider<ScrollAreaCornerOpts> {
static from(opts: ScrollAreaCornerOpts) {
return new ScrollAreaCornerProvider(opts);
}
readonly provider: ScrollAreaProvider;
private constructor(opts: ScrollAreaCornerOpts) {
super(opts, 'ScrollArea', 'corner', attrs.corner);
this.provider = ScrollAreaProvider.ctx.get();
}
readonly shouldShow = $derived.by(
() => this.provider.hasOverflowX && this.provider.hasOverflowY,
);
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'data-state': this.shouldShow ? 'visible' : 'hidden',
style: {
width: 'var(--soma-scroll-area-corner-width)',
height: 'var(--soma-scroll-area-corner-height)',
},
} as const),
);
}

Powered by TurnKey Linux.