@pastweb/components-core 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,80 @@
1
+ # Spinner
2
+
3
+ Spinner displays a rotating visual indicator and exposes a polite loading
4
+ status. Its rendering contract targets WCAG 2.2 Level AA.
5
+
6
+ ## When to use
7
+
8
+ Use it for short waits when completion cannot be calculated. Use ProgressBar for
9
+ determinate work. Do not rely on animation alone: keep the accessible label
10
+ specific to the operation.
11
+
12
+ ## Props
13
+
14
+ | PropName | Type | Required | Default | Description |
15
+ |---|---|---:|---:|---|
16
+ | `cls` | `(...args: ClassValue[]) => string` | yes | - | Resolves component and part classes. |
17
+ | `ariaLabel` | `string` | no | `"Loading"` | Accessible status name. Pass an empty string to omit the name. |
18
+ | `decorative` | `boolean` | no | - | Removes status semantics and applies `is-decorative`. |
19
+ | `className` | `string` | no | `""` | Additional root classes. |
20
+
21
+ The default slot supplies the visual glyph and is hidden from assistive
22
+ technology.
23
+
24
+ ## Mediator state
25
+
26
+ | Property | Type | Description |
27
+ |---|---|---|
28
+ | `ariaLabel` | `string \| undefined` | Normalized status name. Decorative and empty labels are omitted. |
29
+ | `ariaLive` | `'polite' \| undefined` | Is `polite` unless decorative. |
30
+ | `role` | `'status' \| undefined` | Is `status` unless decorative. |
31
+
32
+ ## CSS Variables
33
+
34
+ | Variable | Part | State | CSS property | Default | Description | Platforms |
35
+ |---|---|---|---|---|---|---|
36
+ | `--pw_Spinner_root_default_width` | `root` | `default` | `width` | `1.5em` | Indicator width. | Web, Capacitor, NativeScript |
37
+ | `--pw_Spinner_root_default_height` | `root` | `default` | `height` | `1.5em` | Indicator height. | Web, Capacitor, NativeScript |
38
+ | `--pw_Spinner_root_default_color` | `root` | `default` | `color` | `currentcolor` | Indicator colour. | Web, Capacitor, NativeScript |
39
+ | `--pw_Spinner_content_default_animation-duration` | `content` | `default` | `animation-duration` | `2s` | Rotation duration. | Web, Capacitor; NativeScript adapter |
40
+ | `--pw_Spinner_content_default_animation-timing-function` | `content` | `default` | `animation-timing-function` | `linear` | Rotation timing. | Web, Capacitor; NativeScript adapter |
41
+
42
+ ```xts
43
+ import { useMediator, withDefaultProps } from '@pastweb/x';
44
+ import { spinnerMediator } from '@pastweb/components-core/Spinner';
45
+ import { DEFAULT_PROPS } from './constants';
46
+
47
+ export function Spinner(props: SpinnerProps) {
48
+ const p = withDefaultProps<SpinnerDefaultProps>(props, DEFAULT_PROPS);
49
+ const m = useMediator<SpinnerMediator>(spinnerMediator, p);
50
+ const s = m.state;
51
+
52
+ return (
53
+ <div class={p.cls('Spinner', { 'is-decorative': p.decorative }, p.className)}
54
+ role={s.role} aria-label={s.ariaLabel} aria-live={s.ariaLive}>
55
+ <span class={p.cls('content')} aria-hidden="true"><slot /></span>
56
+ </div>
57
+ );
58
+ }
59
+ ```
60
+
61
+ ## Keyboard interaction
62
+
63
+ - Spinner is not interactive. It does not enter the tab order.
64
+
65
+ ## Accessibility notes
66
+
67
+ - The web renderer exposes a named polite status unless decorative.
68
+ - Decorative spinners omit role, name, and live region.
69
+ - Shared web styles disable non-essential rotation under `prefers-reduced-motion`.
70
+
71
+ ## Known limitations
72
+
73
+ - Automated accessibility checks do not by themselves establish WCAG
74
+ conformance.
75
+
76
+ ## Standards
77
+
78
+ - WCAG 2.2: https://www.w3.org/TR/WCAG22/
79
+ - WAI-ARIA status guidance: https://www.w3.org/WAI/ARIA/apg/patterns/
80
+ - GOV.UK components: https://design-system.service.gov.uk/components/
@@ -0,0 +1,132 @@
1
+ # SplitLayout
2
+
3
+ SplitLayout arranges resizable panels separated by pointer- and keyboard-operable
4
+ controls. Its rendering contract follows the WAI-ARIA Window Splitter pattern
5
+ and targets WCAG 2.2 Level AA.
6
+
7
+ ## When to use
8
+
9
+ Use it when users benefit from allocating space between adjacent work areas.
10
+ Avoid it for ordinary responsive page columns, and provide a stacked alternative
11
+ when a small viewport cannot support the minimum panel sizes.
12
+
13
+ ## Props
14
+
15
+ | PropName | Type | Required | Default | Description |
16
+ |---|---|---:|---:|---|
17
+ | `cls` | `(...args: ClassValue[]) => string` | yes | - | Resolves classes. |
18
+ | `ariaLabel` | `string` | no | `'Resizable panels'` | Names the panel group. Pass an empty string to omit the name. |
19
+ | `className` | `string` | no | `''` | Additional root classes. |
20
+ | `orientation` | `'horizontal' \| 'vertical'` | no | `'horizontal'` | Panel and separator axis. |
21
+ | `sizes` | `number[]` | no | equal | Relative percentages normalized to total 100. |
22
+ | `minSize` | `number` | no | `10` | Minimum percentage for each adjacent panel. |
23
+ | `keyboardStep` | `number` | no | `2` | Percentage moved per arrow-key activation. |
24
+ | `disabled` | `boolean` | no | - | Prevents resizing and applies `is-disabled`. |
25
+ | `onSizesChange` | `(sizes: number[]) => void` | no | noop | Reports normalized percentages after resizing. |
26
+
27
+ `SplitLayoutItem` accepts `id`, `ariaLabel`, and `className`. Only
28
+ `SplitLayoutItem` children are supported.
29
+
30
+ ## Mediator integration
31
+
32
+ `SplitLayoutItem` owns pointer resizing through its mediator. Framework
33
+ renderers should forward `pointerdown`, `pointermove`, `pointerup`,
34
+ `pointercancel`, and `lostpointercapture` events to the matching mediator
35
+ methods. The mediator normalizes the framework event internally, manages
36
+ pointer capture, and reports resize deltas to the parent layout context.
37
+
38
+ The `onSizesChange` prop remains the framework-neutral callback for changed
39
+ pane percentages. Renderers may additionally expose that callback through
40
+ their native event convention, such as Vue's `@sizes-change`.
41
+
42
+ ## Mediator state
43
+
44
+ | Property | Type | Description |
45
+ |---|---|---|
46
+ | `activeSeparator` | `number \| null` | Index of the separator currently being resized. |
47
+ | `ariaLabel` | `string \| undefined` | Normalized group name. Empty labels are omitted. |
48
+ | `isDisabled` | `true \| undefined` | Is `true` when resizing is unavailable; otherwise omitted. |
49
+ | `itemCount` | `number` | Registered pane count. |
50
+ | `sizes` | `number[]` | Normalized pane percentages totalling 100. |
51
+
52
+ ## CSS Variables
53
+
54
+ | Variable | Part | State | CSS property | Default | Description | Platforms |
55
+ |---|---|---|---|---|---|---|
56
+ | `--pw_SplitLayout_root_default_width` | `root` | `default` | `width` | `100%` | Layout width. | Web, Capacitor, NativeScript |
57
+ | `--pw_SplitLayout_root_default_height` | `root` | `default` | `height` | `100%` | Layout height. | Web, Capacitor, NativeScript |
58
+ | `--pw_SplitLayout_root_disabled_opacity` | `root` | `disabled` | `opacity` | shared disabled opacity | Disabled appearance. | Web, Capacitor, NativeScript |
59
+ | `--pw_SplitLayout_pane_default_background-color` | `pane` | `default` | `background-color` | transparent | Panel background. | Web, Capacitor, NativeScript |
60
+ | `--pw_SplitLayout_splitter_default_width` | `splitter` | `default` | `width` | `0.2em` | Horizontal visual splitter width. | Web, Capacitor, NativeScript |
61
+ | `--pw_SplitLayout_splitter_default_height` | `splitter` | `default` | `height` | `0.2em` | Vertical visual splitter height. | Web, Capacitor, NativeScript |
62
+ | `--pw_SplitLayout_splitter_default_background-color` | `splitter` | `default` | `background-color` | border colour | Splitter colour. | Web, Capacitor, NativeScript |
63
+ | `--pw_SplitLayout_splitter_hover_background-color` | `splitter` | `hover` | `background-color` | strong text | Hover colour. | Web, Capacitor |
64
+ | `--pw_SplitLayout_splitter_default_border-color` | `splitter` | `default` | `border-color` | shared border | Splitter border colour. | Web, Capacitor, NativeScript |
65
+ | `--pw_SplitLayout_splitter_default_border-style` | `splitter` | `default` | `border-style` | shared border style | Splitter border style. | Web, Capacitor, NativeScript |
66
+ | `--pw_SplitLayout_splitter_default_border-width` | `splitter` | `default` | `border-width` | shared border width | Splitter border width. | Web, Capacitor, NativeScript |
67
+ | `--pw_SplitLayout_splitter_default_outline` | `splitter` | `default` | `outline` | shared outline | Keyboard focus indicator. | Web, Capacitor |
68
+ | `--pw_SplitLayout_splitter-target_default_width` | `splitter-target` | `default` | `width` | `2.75em` | Horizontal pointer target width. | Web, Capacitor, NativeScript |
69
+ | `--pw_SplitLayout_splitter-target_default_height` | `splitter-target` | `default` | `height` | `2.75em` | Vertical pointer target height. | Web, Capacitor, NativeScript |
70
+
71
+ ```xts
72
+ import { setContext, useBeforeUnmount, useMediator, withDefaultProps } from '@pastweb/x';
73
+ import { splitLayoutMediator, SPLIT_LAYOUT_CONTEXT_KEY } from '@pastweb/components-core/SplitLayout';
74
+ import { DEFAULT_PROPS } from './constants';
75
+
76
+ export function SplitLayout(props: SplitLayoutProps) {
77
+ const p = withDefaultProps<SplitLayoutDefaultProps>(props, DEFAULT_PROPS);
78
+ const m = useMediator<SplitLayoutMediator>(splitLayoutMediator, p);
79
+ const s = m.state;
80
+ const c = m.getContext();
81
+
82
+ m.setItemCount(props.children.length);
83
+ setContext(SPLIT_LAYOUT_CONTEXT_KEY, c);
84
+ useBeforeUnmount(m.useBeforeUnmount);
85
+
86
+ return (
87
+ <div class={p.cls('SplitLayout', p.orientation, {
88
+ 'is-disabled': s.isDisabled, 'is-resizing': s.activeSeparator !== null,
89
+ }, p.className)} role="group" aria-label={s.ariaLabel}>
90
+ <slot />
91
+ </div>
92
+ );
93
+ }
94
+ ```
95
+
96
+ ## Keyboard interaction
97
+
98
+ - A horizontal panel layout has vertical separators and uses `ArrowLeft` and
99
+ `ArrowRight`.
100
+ - A vertical panel layout has horizontal separators and uses `ArrowUp` and
101
+ `ArrowDown`.
102
+ - `Home` moves the preceding panel to its minimum.
103
+ - `End` moves the following panel to its minimum.
104
+ - Separators expose orientation and current, minimum, and maximum percentage
105
+ values.
106
+
107
+ ## Accessibility notes
108
+
109
+ - The layout is a named group. Each separator is a focusable `separator` with
110
+ `aria-controls`, `aria-orientation`, and current/min/max values.
111
+ - Disabled state is reflected through `.is-disabled` and removes separator
112
+ operation.
113
+ - Pointer targets remain at least 44 CSS pixels even when the visible splitter
114
+ is thinner.
115
+
116
+ ## Known limitations
117
+
118
+ - Percentages describe the pane proportions within the space left after
119
+ splitters; very small containers can therefore produce a physical pane size
120
+ slightly below the percentage calculated against the full container.
121
+ - The component does not yet observe container resizing or automatically switch
122
+ orientation at responsive breakpoints.
123
+ - Nested layouts require distinct labels so separator announcements remain
124
+ understandable.
125
+ - Automated accessibility checks do not by themselves establish WCAG
126
+ conformance.
127
+
128
+ ## Standards
129
+
130
+ - WCAG 2.2: https://www.w3.org/TR/WCAG22/
131
+ - WAI-ARIA Window Splitter pattern: https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/
132
+ - GOV.UK components: https://design-system.service.gov.uk/components/
@@ -0,0 +1,146 @@
1
+ # Stepper
2
+
3
+ Stepper communicates progress through an ordered sequence. `active` remains
4
+ one-based for compatibility. A step is interactive only when it supplies
5
+ `onClick`; disabled interactive steps use a native disabled button. Its
6
+ rendering contract targets WCAG 2.2 Level AA.
7
+
8
+ ## When to use
9
+
10
+ Use Stepper for a known sequence of stages. Do not use it as a page navigation
11
+ control unless each step is also a destination. Prefer a native continue action
12
+ and back link for linear form journeys that do not need a visible map of every
13
+ stage.
14
+
15
+ ## Props
16
+
17
+ | PropName | Type | Required | Default | Description |
18
+ |---|---|---:|---:|---|
19
+ | `cls` | `(...args: ClassValue[]) => string` | yes | - | Resolves component and state classes. |
20
+ | `steps` | `Step[]` | yes | - | Ordered labels, descriptions, indicators and optional actions. |
21
+ | `active` | `number` | no | `1` | Current one-based step. |
22
+ | `id` | `string` | no | generated | Supplies the host ID. Generated IDs use the `stepper` prefix. Explicit IDs are stored as `_${id}`. |
23
+ | `ariaLabel` | `string` | no | `"Progress"` | Names the ordered sequence. Pass an empty string to omit it. |
24
+ | `orientation` | `'horizontal' \| 'vertical'` | no | `'horizontal'` | Visual direction. |
25
+ | `labelPosition` | `'top' \| 'bottom' \| 'left' \| 'right'` | no | `'top'` | Visual label position. Horizontal uses `top` or `bottom` (`top` when omitted or invalid). Vertical uses `left` or `right` (`right` when omitted or invalid). |
26
+ | `className` | `string` | no | `""` | Additional root classes. |
27
+
28
+ `Step` supports `label`, `description`, `content`, `ariaLabel`, `className`,
29
+ `disabled`, and `onClick`. The current list item exposes `aria-current="step"`.
30
+
31
+ ## Mediator state
32
+
33
+ | Property | Type | Description |
34
+ |---|---|---|
35
+ | `ariaLabel` | `string \| undefined` | Normalized sequence name. Empty labels are omitted. |
36
+ | `id` | `string` | Host ID from an explicit `id` or a generated `stepper` prefix. |
37
+ | `labelPosition` | `'top' \| 'bottom' \| 'left' \| 'right'` | Resolved label side for the current orientation. |
38
+
39
+ `getStepState(index)` returns `'active'`, `'complete'`, or `'pending'`. Title
40
+ and description IDs use `title${id}${index}` and `description${id}${index}`.
41
+
42
+ ## CSS classes
43
+
44
+ | Class | Applied when | Notes |
45
+ |---|---|---|
46
+ | `Stepper` | Always | Root component class. |
47
+ | `is-horizontal` / `is-vertical` | `orientation` | Axis of the ordered list. |
48
+ | `labels-top` / `labels-bottom` / `labels-left` / `labels-right` | resolved `labelPosition` | Horizontal applies `labels-top` or `labels-bottom`. Vertical applies `labels-left` or `labels-right`. |
49
+ | `is-active` | current step | Applied to the list item. |
50
+ | `is-complete` | earlier steps | Applied to the list item. |
51
+ | `is-pending` | later steps | Applied to the list item. |
52
+ | `is-disabled` | `step.disabled` | Applied to the list item. |
53
+
54
+ ## CSS Variables
55
+
56
+ | Variable | Part | State | CSS property | Default | Description | Platforms |
57
+ |---|---|---|---|---|---|---|
58
+ | `--pw_Stepper_root_default_background-color` | `root` | `default` | `background-color` | `var(--pw_background-color)` | Component background. | Web, Capacitor, NativeScript |
59
+ | `--pw_Stepper_root_disabled_opacity` | `root` | `disabled` | `opacity` | shared disabled opacity | Disabled step appearance. | Web, Capacitor, NativeScript |
60
+ | `--pw_Stepper_indicator_default_background-color` | `indicator` | `default` | `background-color` | `var(--pw_control_background-color)` | Indicator background. | Web, Capacitor, NativeScript |
61
+ | `--pw_Stepper_indicator_active_border-color` | `indicator` | `active` | `border-color` | strong text | Active indicator border. | Web, Capacitor, NativeScript |
62
+ | `--pw_Stepper_indicator_complete_background-color` | `indicator` | `complete` | `background-color` | shared text | Complete indicator background. | Web, Capacitor, NativeScript |
63
+ | `--pw_Stepper_indicator_default_outline` | `indicator` | `default` | `outline` | shared outline | Keyboard focus indicator. | Web, Capacitor |
64
+ | `--pw_Stepper_indicator-target_default_width` | `indicator-target` | `default` | `width` | `2.75em` | Pointer target width. | Web, Capacitor, NativeScript |
65
+ | `--pw_Stepper_indicator-target_default_height` | `indicator-target` | `default` | `height` | `2.75em` | Pointer target height. | Web, Capacitor, NativeScript |
66
+ | `--pw_Stepper_indicator_slot_size` | `indicator` | `default` | `width` / `height` | calculated | Indicator box used to centre labels. | Web, Capacitor |
67
+ | `--pw_Stepper_label_default_color` | `label` | `default` | `color` | `var(--pw_text_color)` | Label colour. | Web, Capacitor, NativeScript |
68
+
69
+ ```xts
70
+ import { useBeforeUnmount, useMediator, withDefaultProps } from '@pastweb/x';
71
+ import { stepperMediator } from '@pastweb/components-core/Stepper';
72
+ import { DEFAULT_PROPS, DEFAULT_STEP_PROPS } from './constants';
73
+
74
+ export function Stepper(props: StepperProps) {
75
+ const p = withDefaultProps<StepperDefaultProps>(props, DEFAULT_PROPS);
76
+ const m = useMediator<StepperMediator>(stepperMediator, p);
77
+ const s = m.state;
78
+ const steps = p.steps.map(step => withDefaultProps<StepDefaultProps>(step, DEFAULT_STEP_PROPS));
79
+
80
+ useBeforeUnmount(m.useBeforeUnmount);
81
+
82
+ return (
83
+ <div id={m.id} class={p.cls('Stepper', `is-${p.orientation}`, `labels-${s.labelPosition}`, p.className)}>
84
+ <ol class={p.cls('steps')} aria-label={s.ariaLabel}>
85
+ {steps.map((step, index) => {
86
+ const state = m.getStepState(index);
87
+ const titleId = `title${m.id}${index}`;
88
+ return (
89
+ <li class={p.cls('step', `is-${state}`, { 'is-disabled': step.disabled }, step.className)}
90
+ aria-current={state === 'active' ? 'step' : undefined}>
91
+ <div class={p.cls('step-main')}>
92
+ <button #if={step.onClick !== DEFAULT_STEP_PROPS.onClick} type="button"
93
+ class={p.cls('indicator')} disabled={step.disabled} @click={step.onClick}
94
+ aria-label={step.ariaLabel} aria-labelledby={!step.ariaLabel && step.label ? titleId : undefined}>
95
+ <span class={p.cls('indicator-content')} aria-hidden="true">{step.content ?? index + 1}</span>
96
+ </button>
97
+ <span #else class={p.cls('indicator')}>
98
+ <span class={p.cls('indicator-content')} aria-hidden="true">{step.content ?? index + 1}</span>
99
+ </span>
100
+ <span #if={index < steps.length - 1} class={p.cls('line')} aria-hidden="true" />
101
+ </div>
102
+ <div #if={step.label || step.description} class={p.cls('label')}>
103
+ <span #if={step.label} id={titleId} class={p.cls('title')}>{step.label}</span>
104
+ <small #if={step.description} id={`description${m.id}${index}`}>{step.description}</small>
105
+ </div>
106
+ </li>
107
+ );
108
+ })}
109
+ </ol>
110
+ </div>
111
+ );
112
+ }
113
+ ```
114
+
115
+ ## Keyboard interaction
116
+
117
+ - Interactive steps are native buttons operable with `Enter` or `Space`.
118
+ - Disabled interactive steps are skipped in the tab order.
119
+ - Non-interactive steps do not enter the tab order.
120
+
121
+ ## Accessibility notes
122
+
123
+ - The sequence is a named ordered list. The current item exposes `aria-current="step"`.
124
+ - Visible labels name interactive steps through `aria-labelledby`. Empty `ariaLabel` values fall back to `Step N of M` when no visible label is present.
125
+ - Indicator glyphs are hidden from assistive technology.
126
+ - Connecting lines are decorative.
127
+ - Pointer targets remain at least 44 CSS pixels even when the visible indicator is smaller.
128
+ - Shared web styles keep a visible `:focus-visible` indicator.
129
+
130
+ ## Known limitations
131
+
132
+ - Nested steppers require distinct labels so the sequences remain distinguishable.
133
+ - Automated accessibility checks do not by themselves establish WCAG conformance.
134
+
135
+ ## Native renderer mapping
136
+
137
+ Native renderers use an accessible ordered collection. Do not copy ARIA
138
+ attributes into native markup. Interactive steps map to native buttons with
139
+ native disabled state. Focus restoration and current-step identification use
140
+ the platform equivalent of `aria-current="step"`.
141
+
142
+ ## Standards
143
+
144
+ - WCAG 2.2: https://www.w3.org/TR/WCAG22/
145
+ - WAI-ARIA APG patterns: https://www.w3.org/WAI/ARIA/apg/patterns/
146
+ - GOV.UK task list: https://design-system.service.gov.uk/components/task-list/
@@ -0,0 +1,76 @@
1
+ # Table
2
+
3
+ Table provides data-grid primitives for column visibility, ordering, pinning,
4
+ sorting, filtering, grouping, expansion, and row selection. The mediator owns
5
+ the table state and generated identifiers; framework renderers only connect it
6
+ to their native table elements.
7
+
8
+ ## Identifier lifecycle
9
+
10
+ Each mediator instance creates an identifier with `idCache.getId('table')`.
11
+ Renderers use `m.id` as the prefix for header and cell identifiers, keeping ARIA
12
+ relationships unique across multiple tables. `m.useBeforeUnmount()` clears both
13
+ row identity records and the generated identifier.
14
+
15
+ ## Mediator API
16
+
17
+ | Property | Type | Description |
18
+ |---|---|---|
19
+ | `id` | `string` | Unique generated prefix for table accessibility identifiers. |
20
+ | `state` | `TableState` | Reactive columns, rows, controlled values, and drag state. |
21
+ | `table` | `TableInstance` | Public table instance used by consumers and slots. |
22
+ | `onMounted` | `(element: HTMLTableElement) => void` | Attaches the rendered table so header groups can be measured. |
23
+ | `onScroll` | `(event: unknown) => void` | Forwards scrolling from the renderer's scrollable table body. |
24
+ | `useBeforeUnmount` | `() => void` | Releases row IDs and the table identifier. |
25
+
26
+ ## Renderer contract
27
+
28
+ ```xts
29
+ import { useBeforeUnmount, useMediator, useMounted, withDefaultProps } from '@pastweb/x';
30
+ import { tableMediator } from '@pastweb/components-core/Table';
31
+ import { DEFAULT_PROPS } from './constants';
32
+
33
+ export function Table(props: TableProps) {
34
+ const p = withDefaultProps<TableDefaultProps>(props, DEFAULT_PROPS);
35
+ const m = useMediator<TableMediator>(tableMediator, p);
36
+ const s = m.state;
37
+
38
+ useMounted(element => m.onMounted(element));
39
+ useBeforeUnmount(m.useBeforeUnmount);
40
+
41
+ return (
42
+ <table aria-label={p.caption ? undefined : p.ariaLabel}>
43
+ <caption #if={p.caption}>{p.caption}</caption>
44
+ <thead>
45
+ <tr #for={group of s.headerGroups}>
46
+ <th
47
+ #for={header of group.headers}
48
+ id={`${m.id}-${header.column.id}`}
49
+ scope="col"
50
+ >
51
+ <render content={header.column.columnDef.header} props={{ column: header.column }} />
52
+ </th>
53
+ </tr>
54
+ </thead>
55
+ <tbody>{/* render s.rows with the same m.id prefix for labelled cells */}</tbody>
56
+ </table>
57
+ );
58
+ }
59
+ ```
60
+
61
+ Always register `m.useBeforeUnmount` in the framework renderer. Do not create
62
+ table identifiers in a framework-specific hook: a mediator-owned ID makes the
63
+ same accessibility contract portable to every supported renderer.
64
+
65
+ ## Body scrolling
66
+
67
+ Renderers should attach `m.onScroll` to the scrollable element that contains the
68
+ table body. The core mediator deliberately forwards the event without assuming
69
+ a browser event type, so each renderer can use its native event while consumers
70
+ can implement behaviors such as infinite loading.
71
+
72
+ ```ts
73
+ <div onScroll={m.onScroll}>
74
+ <table><!-- rows --></table>
75
+ </div>
76
+ ```