@ultimat3/ui 23.0.0 → 25.0.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.
- package/CATALOG.md +119 -15
- package/CLAUDE.md +17 -8
- package/README.md +119 -33
- package/package.json +6 -6
- package/src/a11y.ts +27 -13
- package/src/catalog/build-catalog.ts +2 -2
- package/src/components/Accordion.tsx +8 -2
- package/src/components/AppShell.module.scss +105 -4
- package/src/components/AppShell.tsx +48 -10
- package/src/components/BarChart.module.scss +47 -9
- package/src/components/BarChart.tsx +152 -72
- package/src/components/Card.tsx +52 -16
- package/src/components/ChartFrame.module.scss +188 -0
- package/src/components/ChartFrame.tsx +155 -0
- package/src/components/CommandPalette.module.scss +8 -5
- package/src/components/Container.tsx +46 -9
- package/src/components/DataTable.module.scss +190 -0
- package/src/components/DataTable.tsx +161 -51
- package/src/components/Divider.tsx +0 -2
- package/src/components/DonutChart.module.scss +73 -0
- package/src/components/DonutChart.tsx +154 -0
- package/src/components/Dropzone.module.scss +3 -1
- package/src/components/Dropzone.tsx +17 -8
- package/src/components/ErrorState.tsx +39 -12
- package/src/components/FileInput.module.scss +3 -1
- package/src/components/FileInput.tsx +6 -2
- package/src/components/Form.tsx +17 -4
- package/src/components/Gauge.module.scss +48 -0
- package/src/components/Gauge.tsx +67 -0
- package/src/components/Grid.tsx +34 -13
- package/src/components/Image.tsx +2 -2
- package/src/components/InlineBar.module.scss +23 -0
- package/src/components/InlineBar.tsx +31 -0
- package/src/components/LineChart.module.scss +147 -0
- package/src/components/LineChart.tsx +169 -0
- package/src/components/Menu.tsx +9 -5
- package/src/components/Meter.tsx +3 -3
- package/src/components/PageHeader.tsx +5 -2
- package/src/components/Popover.module.scss +17 -20
- package/src/components/Popover.tsx +14 -7
- package/src/components/Section.tsx +45 -26
- package/src/components/Stack.tsx +59 -18
- package/src/components/Table.module.scss +30 -0
- package/src/components/Table.tsx +14 -3
- package/src/components/Tabs.module.scss +6 -0
- package/src/components/Tabs.tsx +2 -2
- package/src/components/Text.tsx +41 -15
- package/src/components/Textarea.tsx +7 -4
- package/src/components/Toaster.tsx +1 -1
- package/src/components/Toolbar.tsx +7 -6
- package/src/components/Tooltip.module.scss +8 -3
- package/src/components/app-shell-view.ts +6 -4
- package/src/components/bar-chart-view.ts +11 -6
- package/src/components/chart-frame-view.ts +219 -0
- package/src/components/chart-ticks-view.ts +173 -0
- package/src/components/data-table-view.ts +68 -0
- package/src/components/donut-chart-view.ts +120 -0
- package/src/components/file-input-view.ts +32 -13
- package/src/components/gauge-view.ts +43 -0
- package/src/components/heading-node.tsx +62 -0
- package/src/components/image-source.ts +3 -3
- package/src/components/line-chart-view.ts +153 -0
- package/src/components/link-target.ts +21 -1
- package/src/components/meter-view.ts +13 -0
- package/src/components/money-view.ts +2 -7
- package/src/components/qr-matrix.ts +16 -23
- package/src/components/relative-time-view.ts +11 -4
- package/src/form/form-binding.ts +32 -14
- package/src/form/use-form.ts +3 -3
- package/src/global.scss +9 -0
- package/src/icons/build-icons.ts +109 -24
- package/src/index.ts +47 -15
- package/src/jsx-probe.ts +5 -4
- package/src/theme/brand-declarations.ts +113 -0
- package/src/theme/brand.ts +104 -96
- package/src/theme/context.ts +1 -1
- package/src/theme/preset-scifi.ts +99 -0
- package/src/theme/preset-shape.ts +18 -0
- package/src/theme/presets.ts +15 -0
- package/src/theme/shadow-value.ts +39 -0
- package/src/theme/theme.ts +36 -11
- package/src/toast/toast-state.ts +0 -0
- package/src/toast/toast-store.ts +1 -1
- package/src/toast/use-toasts.ts +1 -1
- package/src/tokens/_colors.scss +20 -0
- package/src/tokens/_effects.scss +59 -0
- package/src/tokens/_index.scss +2 -0
- package/src/tokens/_mixins.scss +6 -4
- package/src/tokens/_shadow.scss +16 -0
- package/src/tokens/_touch.scss +39 -0
- package/src/tokens/_typography.scss +14 -0
- package/src/tokens/color-tokens.ts +133 -0
- package/src/tokens/colour-vision.ts +106 -0
- package/src/tokens/contrast-pairs.ts +6 -1
- package/src/tokens/contrast.ts +1 -1
- package/src/tokens/theme.scss +2 -0
- package/src/tokens/tokens.ts +41 -87
- package/src/fake-dom.ts +0 -206
- package/src/sass-probe.ts +0 -55
- package/src/theme/inline-script.ts +0 -49
package/src/components/Menu.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Distinct from Select — a menu runs commands, it does not hold a form value.
|
|
3
3
|
|
|
4
4
|
import type { JSX } from 'solid-js';
|
|
5
|
-
import {
|
|
5
|
+
import { focusTrap, rovingTabindex, useId } from '../a11y';
|
|
6
6
|
import { cx } from '../cx';
|
|
7
7
|
import { MENU_ITEM_SELECTOR, tabStopIndex } from '../roving';
|
|
8
8
|
import { useUi } from '../theme/context';
|
|
@@ -24,11 +24,15 @@ export interface MenuProps {
|
|
|
24
24
|
items: readonly MenuItem[];
|
|
25
25
|
open: boolean;
|
|
26
26
|
onOpenChange: (open: boolean) => void;
|
|
27
|
+
/**
|
|
28
|
+
* The menu button. `aria-controls` is present only while the list is mounted: an IDREF to an
|
|
29
|
+
* element not in the document names nothing.
|
|
30
|
+
*/
|
|
27
31
|
trigger: (control: {
|
|
28
32
|
id: string;
|
|
29
33
|
'aria-haspopup': 'menu';
|
|
30
34
|
'aria-expanded': boolean;
|
|
31
|
-
'aria-controls': string;
|
|
35
|
+
'aria-controls': string | undefined;
|
|
32
36
|
}) => JSX.Element;
|
|
33
37
|
/** Already-translated accessible name for the menu. */
|
|
34
38
|
label: string;
|
|
@@ -45,7 +49,7 @@ export function Menu(props: MenuProps): JSX.Element {
|
|
|
45
49
|
|
|
46
50
|
// Disabled items are excluded from BOTH answers — the list arrows walk and the one item that
|
|
47
51
|
// carries the tab stop — because `focus()` on a disabled button silently does nothing.
|
|
48
|
-
const onKeyDown =
|
|
52
|
+
const onKeyDown = rovingTabindex(
|
|
49
53
|
() =>
|
|
50
54
|
list === undefined ? [] : Array.from(list.querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR)),
|
|
51
55
|
{ orientation: 'vertical', dir: ui.dir },
|
|
@@ -65,7 +69,7 @@ export function Menu(props: MenuProps): JSX.Element {
|
|
|
65
69
|
// Closing unmounts the panel with focus inside it, which resets focus to <body> and makes the
|
|
66
70
|
// next Tab restart at the top of the document. The trap moves focus in on open and hands it
|
|
67
71
|
// back to the trigger on close, so Escape returns the user where they were.
|
|
68
|
-
const trap = list === undefined ? undefined :
|
|
72
|
+
const trap = list === undefined ? undefined : focusTrap(list);
|
|
69
73
|
trap?.activate();
|
|
70
74
|
rt.onCleanup(() => {
|
|
71
75
|
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
@@ -85,7 +89,7 @@ export function Menu(props: MenuProps): JSX.Element {
|
|
|
85
89
|
id: `${menuId}-trigger`,
|
|
86
90
|
'aria-haspopup': 'menu',
|
|
87
91
|
'aria-expanded': props.open,
|
|
88
|
-
'aria-controls': menuId,
|
|
92
|
+
'aria-controls': props.open ? menuId : undefined,
|
|
89
93
|
})}
|
|
90
94
|
{props.open ? (
|
|
91
95
|
<div
|
package/src/components/Meter.tsx
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { JSX } from 'solid-js';
|
|
10
10
|
import { cx } from '../cx';
|
|
11
11
|
import styles from './Meter.module.scss';
|
|
12
|
-
import { meterShare, meterWidth } from './meter-view';
|
|
12
|
+
import { meterAria, meterShare, meterWidth } from './meter-view';
|
|
13
13
|
import type { Tone } from './variants';
|
|
14
14
|
|
|
15
15
|
export interface MeterProps {
|
|
@@ -49,8 +49,8 @@ export function Meter(props: MeterProps): JSX.Element {
|
|
|
49
49
|
role="meter"
|
|
50
50
|
aria-label={props.label}
|
|
51
51
|
aria-valuemin={0}
|
|
52
|
-
aria-valuemax={props.max}
|
|
53
|
-
aria-valuenow={props.value}
|
|
52
|
+
aria-valuemax={meterAria(props.value, props.max).max}
|
|
53
|
+
aria-valuenow={meterAria(props.value, props.max).now}
|
|
54
54
|
>
|
|
55
55
|
{bars()}
|
|
56
56
|
</svg>
|
|
@@ -6,6 +6,7 @@ import type { JSX } from 'solid-js';
|
|
|
6
6
|
import { cx } from '../cx';
|
|
7
7
|
import { Breadcrumb, type BreadcrumbItem } from './Breadcrumb';
|
|
8
8
|
import { type HeadingLevel, headingTag } from './heading-level';
|
|
9
|
+
import { headingNode } from './heading-node';
|
|
9
10
|
import styles from './PageHeader.module.scss';
|
|
10
11
|
|
|
11
12
|
export interface PageHeaderProps {
|
|
@@ -24,7 +25,9 @@ export interface PageHeaderProps {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
export function PageHeader(props: PageHeaderProps): JSX.Element {
|
|
27
|
-
|
|
28
|
+
// Resolved once, so an off-scale level throws `X_UI_INVALID_VALUE` here as it always did.
|
|
29
|
+
const heading = headingTag(props.level ?? 1);
|
|
30
|
+
const title = (): string => props.title;
|
|
28
31
|
|
|
29
32
|
return (
|
|
30
33
|
<header class={cx(styles['pageHeader'], props.class)}>
|
|
@@ -33,7 +36,7 @@ export function PageHeader(props: PageHeaderProps): JSX.Element {
|
|
|
33
36
|
<div class={styles['titles']}>
|
|
34
37
|
{props.media === undefined ? null : <div class={styles['media']}>{props.media}</div>}
|
|
35
38
|
<div class={styles['text']}>
|
|
36
|
-
|
|
39
|
+
{headingNode(heading, { class: styles['title'] }, title)}
|
|
37
40
|
{props.description === undefined ? null : (
|
|
38
41
|
<p class={styles['description']}>{props.description}</p>
|
|
39
42
|
)}
|
|
@@ -33,27 +33,24 @@
|
|
|
33
33
|
inset-block-start: 0;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
.align-center {
|
|
45
|
-
inset-inline-start: 50%;
|
|
46
|
-
translate: -50% 0;
|
|
47
|
-
}
|
|
36
|
+
// Alignment is along the anchor's inline edge, so it exists only for the block placements. An
|
|
37
|
+
// inline placement owns both inline insets itself; an align rule that reached it (and the reset
|
|
38
|
+
// that used to undo one) wiped the placement's own inset and dropped the panel over the anchor.
|
|
39
|
+
.placement-block-end,
|
|
40
|
+
.placement-block-start {
|
|
41
|
+
&.align-start {
|
|
42
|
+
inset-inline-start: 0;
|
|
43
|
+
}
|
|
48
44
|
|
|
49
|
-
// Inline placements own their own inline axis; alignment must not fight them.
|
|
50
|
-
.placement-inline-end,
|
|
51
|
-
.placement-inline-start {
|
|
52
|
-
&.align-start,
|
|
53
|
-
&.align-center,
|
|
54
45
|
&.align-end {
|
|
55
|
-
inset-inline-
|
|
56
|
-
|
|
57
|
-
|
|
46
|
+
inset-inline-end: 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Auto margins inside an inset band far wider than any panel: centred in either direction. A 50%
|
|
50
|
+
// logical inset with a -50% physical translate is a whole width off under `dir="rtl"`.
|
|
51
|
+
&.align-center {
|
|
52
|
+
inset-inline: -100vw;
|
|
53
|
+
margin-inline: auto;
|
|
54
|
+
inline-size: fit-content;
|
|
58
55
|
}
|
|
59
56
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Non-modal anchored panel. Positioning is CSS-only (a relatively positioned
|
|
2
|
-
// anchor plus logical insets), so there is no measure/reflow
|
|
3
|
-
//
|
|
2
|
+
// anchor plus logical insets and auto-margin centring), so there is no measure/reflow
|
|
3
|
+
// loop and the panel mirrors under `dir="rtl"` — `rtl-sheets.test.ts` holds that.
|
|
4
4
|
|
|
5
5
|
import type { JSX } from 'solid-js';
|
|
6
|
-
import {
|
|
6
|
+
import { focusTrap, useId } from '../a11y';
|
|
7
7
|
import { cx } from '../cx';
|
|
8
8
|
import { solid } from '../theme/solid-adapter';
|
|
9
9
|
import styles from './Popover.module.scss';
|
|
@@ -13,11 +13,14 @@ export type Placement = 'block-end' | 'block-start' | 'inline-end' | 'inline-sta
|
|
|
13
13
|
export interface PopoverProps {
|
|
14
14
|
open: boolean;
|
|
15
15
|
onOpenChange: (open: boolean) => void;
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* The anchor. Wire its onClick to `onOpenChange` in the caller. `aria-controls` is present only
|
|
18
|
+
* while the panel is mounted: an IDREF to an element not in the document names nothing.
|
|
19
|
+
*/
|
|
17
20
|
trigger: (control: {
|
|
18
21
|
id: string;
|
|
19
22
|
'aria-expanded': boolean;
|
|
20
|
-
'aria-controls': string;
|
|
23
|
+
'aria-controls': string | undefined;
|
|
21
24
|
}) => JSX.Element;
|
|
22
25
|
children: JSX.Element;
|
|
23
26
|
/** Already-translated accessible name for the panel. */
|
|
@@ -47,7 +50,7 @@ export function Popover(props: PopoverProps): JSX.Element {
|
|
|
47
50
|
// Closing unmounts the panel with focus inside it, which drops focus to <body> and makes the
|
|
48
51
|
// next Tab restart at the top of the document. The trap moves focus into the panel on open and
|
|
49
52
|
// hands it back to the trigger on close.
|
|
50
|
-
const trap = panel === undefined ? undefined :
|
|
53
|
+
const trap = panel === undefined ? undefined : focusTrap(panel);
|
|
51
54
|
trap?.activate();
|
|
52
55
|
rt.onCleanup(() => {
|
|
53
56
|
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
@@ -63,7 +66,11 @@ export function Popover(props: PopoverProps): JSX.Element {
|
|
|
63
66
|
root = el;
|
|
64
67
|
}}
|
|
65
68
|
>
|
|
66
|
-
{props.trigger({
|
|
69
|
+
{props.trigger({
|
|
70
|
+
id: triggerId,
|
|
71
|
+
'aria-expanded': props.open,
|
|
72
|
+
'aria-controls': props.open ? panelId : undefined,
|
|
73
|
+
})}
|
|
67
74
|
{props.open ? (
|
|
68
75
|
<div
|
|
69
76
|
ref={(el: HTMLDivElement) => {
|
|
@@ -6,6 +6,7 @@ import type { JSX } from 'solid-js';
|
|
|
6
6
|
import { useId } from '../a11y';
|
|
7
7
|
import { cx } from '../cx';
|
|
8
8
|
import { type HeadingLevel, headingTag } from './heading-level';
|
|
9
|
+
import { headingNode } from './heading-node';
|
|
9
10
|
import styles from './Section.module.scss';
|
|
10
11
|
|
|
11
12
|
export interface SectionProps {
|
|
@@ -23,33 +24,51 @@ export interface SectionProps {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export function Section(props: SectionProps): JSX.Element {
|
|
26
|
-
|
|
27
|
-
const
|
|
27
|
+
// Resolved once, so an off-scale level throws `X_UI_INVALID_VALUE` here as it always did.
|
|
28
|
+
const heading = headingTag(props.level ?? 2);
|
|
28
29
|
const titleId = useId('section');
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
</Heading>
|
|
42
|
-
)}
|
|
43
|
-
{props.description === undefined ? null : (
|
|
44
|
-
<p class={styles['description']}>{props.description}</p>
|
|
45
|
-
)}
|
|
46
|
-
</div>
|
|
47
|
-
{props.actions === undefined ? null : (
|
|
48
|
-
<div class={styles['actions']}>{props.actions}</div>
|
|
30
|
+
const cls = (): string => cx(styles['section'], props.class);
|
|
31
|
+
const labelledBy = (): string | undefined => (props.title === undefined ? undefined : titleId);
|
|
32
|
+
const title = (): string | undefined => props.title;
|
|
33
|
+
const head = (): JSX.Element =>
|
|
34
|
+
props.title === undefined && props.actions === undefined ? null : (
|
|
35
|
+
<div class={styles['head']}>
|
|
36
|
+
<div class={styles['text']}>
|
|
37
|
+
{props.title === undefined
|
|
38
|
+
? null
|
|
39
|
+
: headingNode(heading, { id: titleId, class: styles['title'] }, title)}
|
|
40
|
+
{props.description === undefined ? null : (
|
|
41
|
+
<p class={styles['description']}>{props.description}</p>
|
|
49
42
|
)}
|
|
50
43
|
</div>
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
)
|
|
44
|
+
{props.actions === undefined ? null : <div class={styles['actions']}>{props.actions}</div>}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
const body = (): JSX.Element => <div class={styles['body']}>{props.children}</div>;
|
|
48
|
+
|
|
49
|
+
// One intrinsic per case, never `const Tag = props.as; <Tag>`: the island build compiles a
|
|
50
|
+
// capitalised tag to `createComponent(Tag)` and calls the string (#488, `intrinsic-root.test.ts`).
|
|
51
|
+
switch (props.as ?? 'section') {
|
|
52
|
+
case 'article':
|
|
53
|
+
return (
|
|
54
|
+
<article class={cls()} aria-labelledby={labelledBy()}>
|
|
55
|
+
{head()}
|
|
56
|
+
{body()}
|
|
57
|
+
</article>
|
|
58
|
+
);
|
|
59
|
+
case 'aside':
|
|
60
|
+
return (
|
|
61
|
+
<aside class={cls()} aria-labelledby={labelledBy()}>
|
|
62
|
+
{head()}
|
|
63
|
+
{body()}
|
|
64
|
+
</aside>
|
|
65
|
+
);
|
|
66
|
+
case 'section':
|
|
67
|
+
return (
|
|
68
|
+
<section class={cls()} aria-labelledby={labelledBy()}>
|
|
69
|
+
{head()}
|
|
70
|
+
{body()}
|
|
71
|
+
</section>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
55
74
|
}
|
package/src/components/Stack.tsx
CHANGED
|
@@ -28,22 +28,63 @@ const ALIGN: Readonly<Record<Align, string>> = {
|
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
export function Stack(props: StackProps): JSX.Element {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
31
|
+
const cls = (): string =>
|
|
32
|
+
cx(
|
|
33
|
+
styles['stack'],
|
|
34
|
+
styles[`direction-${props.direction ?? 'column'}`],
|
|
35
|
+
props.wrap === true && styles['wrap'],
|
|
36
|
+
props.class,
|
|
37
|
+
);
|
|
38
|
+
const style = (): JSX.CSSProperties => ({
|
|
39
|
+
'--stack-gap': `var(--space-${props.gap ?? 4})`,
|
|
40
|
+
'--stack-align': ALIGN[props.align ?? 'stretch'],
|
|
41
|
+
'--stack-justify': ALIGN[props.justify ?? 'start'],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// One intrinsic per case, never `const Tag = props.as; <Tag>`: the island build compiles a
|
|
45
|
+
// capitalised tag to `createComponent(Tag)` and calls the string (#488, `intrinsic-root.test.ts`).
|
|
46
|
+
switch (props.as ?? 'div') {
|
|
47
|
+
case 'ul':
|
|
48
|
+
return (
|
|
49
|
+
<ul class={cls()} style={style()}>
|
|
50
|
+
{props.children}
|
|
51
|
+
</ul>
|
|
52
|
+
);
|
|
53
|
+
case 'ol':
|
|
54
|
+
return (
|
|
55
|
+
<ol class={cls()} style={style()}>
|
|
56
|
+
{props.children}
|
|
57
|
+
</ol>
|
|
58
|
+
);
|
|
59
|
+
case 'nav':
|
|
60
|
+
return (
|
|
61
|
+
<nav class={cls()} style={style()}>
|
|
62
|
+
{props.children}
|
|
63
|
+
</nav>
|
|
64
|
+
);
|
|
65
|
+
case 'section':
|
|
66
|
+
return (
|
|
67
|
+
<section class={cls()} style={style()}>
|
|
68
|
+
{props.children}
|
|
69
|
+
</section>
|
|
70
|
+
);
|
|
71
|
+
case 'header':
|
|
72
|
+
return (
|
|
73
|
+
<header class={cls()} style={style()}>
|
|
74
|
+
{props.children}
|
|
75
|
+
</header>
|
|
76
|
+
);
|
|
77
|
+
case 'footer':
|
|
78
|
+
return (
|
|
79
|
+
<footer class={cls()} style={style()}>
|
|
80
|
+
{props.children}
|
|
81
|
+
</footer>
|
|
82
|
+
);
|
|
83
|
+
case 'div':
|
|
84
|
+
return (
|
|
85
|
+
<div class={cls()} style={style()}>
|
|
86
|
+
{props.children}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
49
90
|
}
|
|
@@ -75,3 +75,33 @@
|
|
|
75
75
|
.striped :global(tbody tr:nth-child(even) td) {
|
|
76
76
|
background: t.role('bg-soft', 0.6);
|
|
77
77
|
}
|
|
78
|
+
|
|
79
|
+
// The first column stays at the inline start while the scroller moves the rest. A sticky cell
|
|
80
|
+
// needs an opaque background or the scrolled cells show through it; the edge line is a border,
|
|
81
|
+
// not an inset shadow, because a border is logical and mirrors under RTL by itself.
|
|
82
|
+
.table[data-sticky-first] :global(:is(th, td):first-child) {
|
|
83
|
+
position: sticky;
|
|
84
|
+
inset-inline-start: 0;
|
|
85
|
+
z-index: t.z(raised);
|
|
86
|
+
background-color: t.role('surface');
|
|
87
|
+
border-inline-end: 1px solid t.role('line');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.table[data-sticky-first] :global(th:first-child) {
|
|
91
|
+
background-color: t.role('bg-soft');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The corner cell is sticky on both axes, so it must sit above both the header row and the column.
|
|
95
|
+
.sticky[data-sticky-first] :global(thead th:first-child) {
|
|
96
|
+
z-index: t.z(sticky);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.sticky[data-sticky-first] :global(thead th:not(:first-child)) {
|
|
100
|
+
z-index: t.z(raised);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// A striped row keeps its stripe on the pinned cell: the translucent stripe laid over the opaque
|
|
104
|
+
// surface, so the stripe shows and the scrolled cells still do not.
|
|
105
|
+
.striped[data-sticky-first] :global(tbody tr:nth-child(even) td:first-child) {
|
|
106
|
+
background: linear-gradient(t.role('bg-soft', 0.6), t.role('bg-soft', 0.6)), t.role('surface');
|
|
107
|
+
}
|
package/src/components/Table.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// Presentational table. Owns
|
|
2
|
-
//
|
|
3
|
-
// scroll container so the page body never scrolls sideways.
|
|
1
|
+
// Presentational table. Owns what every hand-rolled table gets wrong: a sticky header, a first
|
|
2
|
+
// column that stays put while the rest scrolls, and horizontal overflow contained inside the
|
|
3
|
+
// table's own scroll container so the page body never scrolls sideways.
|
|
4
4
|
|
|
5
5
|
import type { JSX } from 'solid-js';
|
|
6
6
|
import { cx } from '../cx';
|
|
@@ -13,6 +13,14 @@ export interface TableProps {
|
|
|
13
13
|
/** Hide the caption visually while keeping it for assistive tech. */
|
|
14
14
|
hideCaption?: boolean | undefined;
|
|
15
15
|
stickyHeader?: boolean | undefined;
|
|
16
|
+
/** Pins the first column at the inline start while the table scrolls sideways. Off by default. */
|
|
17
|
+
stickyFirstColumn?: boolean | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Declares `role="table"` on the element itself. For a caller that restyles the table's
|
|
20
|
+
* `display` (DataTable's card view): WebKit drops a table's semantics once `display` changes,
|
|
21
|
+
* and the explicit role is what keeps it a table. The rows and cells are the caller's to mark.
|
|
22
|
+
*/
|
|
23
|
+
explicitRoles?: boolean | undefined;
|
|
16
24
|
density?: 'comfortable' | 'compact' | undefined;
|
|
17
25
|
/** Zebra striping. Off by default — a border is usually enough. */
|
|
18
26
|
striped?: boolean | undefined;
|
|
@@ -27,6 +35,9 @@ export function Table(props: TableProps): JSX.Element {
|
|
|
27
35
|
// inside it — and a <section> with no name is generic, so the table keeps its own caption.
|
|
28
36
|
<section class={cx(styles['scroller'], props.class)} tabindex="0">
|
|
29
37
|
<table
|
|
38
|
+
role={props.explicitRoles === true ? 'table' : undefined}
|
|
39
|
+
// An attribute, not a class: it is state the stylesheet and a test both read.
|
|
40
|
+
data-sticky-first={props.stickyFirstColumn === true ? '' : undefined}
|
|
30
41
|
class={cx(
|
|
31
42
|
styles['table'],
|
|
32
43
|
styles[`density-${props.density ?? 'comfortable'}`],
|
|
@@ -18,11 +18,17 @@
|
|
|
18
18
|
border-inline-end: 1px solid t.role('line');
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// On the list's inline-END edge, beside its border. A shadow's x offset is physical, so RTL —
|
|
22
|
+
// where that edge is the left one — gets the mirrored sign, as `Switch.module.scss` does.
|
|
21
23
|
.tab[aria-selected='true'] {
|
|
22
24
|
box-shadow: inset calc(-1 * #{t.stroke(thick)}) 0 0 0 t.role('accent');
|
|
23
25
|
}
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
[dir='rtl'] .orientation-vertical .tab[aria-selected='true'] {
|
|
29
|
+
box-shadow: inset t.stroke(thick) 0 0 0 t.role('accent');
|
|
30
|
+
}
|
|
31
|
+
|
|
26
32
|
.list {
|
|
27
33
|
@include t.custom-scrollbar;
|
|
28
34
|
display: flex;
|
package/src/components/Tabs.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// between them, Home/End jump to the ends, and arrow direction follows `dir`.
|
|
3
3
|
|
|
4
4
|
import type { JSX } from 'solid-js';
|
|
5
|
-
import { ariaBool,
|
|
5
|
+
import { ariaBool, rovingTabindex, useId } from '../a11y';
|
|
6
6
|
import { cx } from '../cx';
|
|
7
7
|
import { TAB_SELECTOR, tabStopIndex } from '../roving';
|
|
8
8
|
import { useUi } from '../theme/context';
|
|
@@ -35,7 +35,7 @@ export function Tabs(props: TabsProps): JSX.Element {
|
|
|
35
35
|
// A disabled tab is excluded from BOTH answers — the list arrows walk and the one tab that
|
|
36
36
|
// carries the tab stop — because `focus()` on a disabled button silently does nothing, so a
|
|
37
37
|
// disabled tab left in the list pins the reducer on its index and hides every tab after it.
|
|
38
|
-
const onKeyDown =
|
|
38
|
+
const onKeyDown = rovingTabindex(
|
|
39
39
|
() => (list === undefined ? [] : Array.from(list.querySelectorAll<HTMLElement>(TAB_SELECTOR))),
|
|
40
40
|
{ orientation: props.orientation ?? 'horizontal', dir: ui.dir },
|
|
41
41
|
);
|
package/src/components/Text.tsx
CHANGED
|
@@ -37,21 +37,47 @@ export interface TextProps {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
export function Text(props: TextProps): JSX.Element {
|
|
40
|
-
const
|
|
41
|
-
|
|
40
|
+
const cls = (): string =>
|
|
41
|
+
cx(styles['text'], styles[`tone-${props.tone ?? 'default'}`], props.class);
|
|
42
42
|
// Size and weight are set only when asked for: unset means inherit, so <Text>
|
|
43
43
|
// inside a heading or a caption does not silently reset to the body scale.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
44
|
+
const style = (): JSX.CSSProperties => ({
|
|
45
|
+
...(props.size === undefined ? {} : { '--text-scale': `var(--text-${props.size})` }),
|
|
46
|
+
...(props.weight === undefined ? {} : { '--text-strength': `var(--weight-${props.weight})` }),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// One intrinsic per case, never `const Tag = props.as; <Tag>`: the island build compiles a
|
|
50
|
+
// capitalised tag to `createComponent(Tag)` and calls the string (#488, `intrinsic-root.test.ts`).
|
|
51
|
+
switch (props.as ?? 'span') {
|
|
52
|
+
case 'p':
|
|
53
|
+
return (
|
|
54
|
+
<p class={cls()} style={style()}>
|
|
55
|
+
{props.children}
|
|
56
|
+
</p>
|
|
57
|
+
);
|
|
58
|
+
case 'div':
|
|
59
|
+
return (
|
|
60
|
+
<div class={cls()} style={style()}>
|
|
61
|
+
{props.children}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
case 'strong':
|
|
65
|
+
return (
|
|
66
|
+
<strong class={cls()} style={style()}>
|
|
67
|
+
{props.children}
|
|
68
|
+
</strong>
|
|
69
|
+
);
|
|
70
|
+
case 'em':
|
|
71
|
+
return (
|
|
72
|
+
<em class={cls()} style={style()}>
|
|
73
|
+
{props.children}
|
|
74
|
+
</em>
|
|
75
|
+
);
|
|
76
|
+
case 'span':
|
|
77
|
+
return (
|
|
78
|
+
<span class={cls()} style={style()}>
|
|
79
|
+
{props.children}
|
|
80
|
+
</span>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
57
83
|
}
|
|
@@ -28,11 +28,13 @@ export interface TextareaProps {
|
|
|
28
28
|
/**
|
|
29
29
|
* `<textarea>` has NO `value` attribute — its value is its text content, and the parser drops the
|
|
30
30
|
* attribute silently, so `value={…}` rendered every admin edit box empty whatever the row held.
|
|
31
|
-
*
|
|
32
|
-
* newline after `<textarea>`, so emitting it unconditionally is what makes a value that
|
|
33
|
-
* starts with a newline survive the round trip.
|
|
31
|
+
* On the SERVER the leading newline is the serializer's, not the value's: the HTML parser strips
|
|
32
|
+
* exactly one newline after `<textarea>`, so emitting it unconditionally is what makes a value that
|
|
33
|
+
* itself starts with a newline survive the round trip. In a BROWSER no parser runs — an island
|
|
34
|
+
* builds the element — so the value is the `.value` property, and the newline would be data.
|
|
34
35
|
*/
|
|
35
36
|
export function Textarea(props: TextareaProps): JSX.Element {
|
|
37
|
+
const onServer = typeof document === 'undefined';
|
|
36
38
|
return (
|
|
37
39
|
<textarea
|
|
38
40
|
class={cx(styles['textarea'], props.autoGrow === true && styles['autoGrow'], props.class)}
|
|
@@ -49,8 +51,9 @@ export function Textarea(props: TextareaProps): JSX.Element {
|
|
|
49
51
|
aria-invalid={ariaBool(props['aria-invalid'])}
|
|
50
52
|
onInput={props.onInput}
|
|
51
53
|
onBlur={props.onBlur}
|
|
54
|
+
value={onServer ? undefined : (props.value ?? '')}
|
|
52
55
|
>
|
|
53
|
-
{`\n${props.value ?? ''}`}
|
|
56
|
+
{onServer ? `\n${props.value ?? ''}` : undefined}
|
|
54
57
|
</textarea>
|
|
55
58
|
);
|
|
56
59
|
}
|
|
@@ -15,7 +15,7 @@ import { Button } from './Button';
|
|
|
15
15
|
import { Toast, type ToastPlacement, ToastRegion } from './Toast';
|
|
16
16
|
|
|
17
17
|
export interface ToasterProps {
|
|
18
|
-
/** The queue. One per app — `
|
|
18
|
+
/** The queue. One per app — `toastStore()` in the island that mounts this. */
|
|
19
19
|
store: ToastStore;
|
|
20
20
|
/** Already-translated landmark name, e.g. "Notifications". */
|
|
21
21
|
label: string;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// own arrow keys, so making the strip a single stop would strand every control past it.
|
|
6
6
|
|
|
7
7
|
import type { JSX } from 'solid-js';
|
|
8
|
-
import {
|
|
8
|
+
import { focusableWithin, rovingTabindex } from '../a11y';
|
|
9
9
|
import { cx } from '../cx';
|
|
10
10
|
import { useUi } from '../theme/context';
|
|
11
11
|
import styles from './Toolbar.module.scss';
|
|
@@ -27,12 +27,13 @@ export function Toolbar(props: ToolbarProps): JSX.Element {
|
|
|
27
27
|
let strip: HTMLDivElement | undefined;
|
|
28
28
|
|
|
29
29
|
// Arrows follow the writing direction; `focusableWithin` skips anything hidden or disabled, and
|
|
30
|
-
// `
|
|
30
|
+
// `rovingTabindex` declines outright while a control that answers arrows itself — the
|
|
31
31
|
// search Input this strip exists to hold — has focus.
|
|
32
|
-
const onKeyDown =
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
const onKeyDown = rovingTabindex(() => (strip === undefined ? [] : focusableWithin(strip)), {
|
|
33
|
+
orientation: 'horizontal',
|
|
34
|
+
dir: ui.dir,
|
|
35
|
+
loop: false,
|
|
36
|
+
});
|
|
36
37
|
|
|
37
38
|
return (
|
|
38
39
|
<div
|
|
@@ -9,8 +9,13 @@
|
|
|
9
9
|
@include t.transition(opacity, translate);
|
|
10
10
|
position: absolute;
|
|
11
11
|
z-index: t.z(tooltip);
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
// Centred over the trigger in either direction: auto margins inside an inset band far wider than
|
|
13
|
+
// any bubble. A 50% logical inset with a -50% physical translate lands a whole width off-centre
|
|
14
|
+
// under `dir="rtl"` (`rtl-sheets.test.ts`).
|
|
15
|
+
inset-inline: -100vw;
|
|
16
|
+
margin-inline: auto;
|
|
17
|
+
inline-size: max-content;
|
|
18
|
+
translate: 0 t.space(1);
|
|
14
19
|
max-inline-size: 18rem;
|
|
15
20
|
padding-block: t.space(1);
|
|
16
21
|
padding-inline: t.space(2);
|
|
@@ -36,6 +41,6 @@
|
|
|
36
41
|
// Hover and keyboard focus both reveal it — a hover-only tooltip is inaccessible.
|
|
37
42
|
.wrap:hover .bubble,
|
|
38
43
|
.wrap:focus-within .bubble {
|
|
39
|
-
translate:
|
|
44
|
+
translate: 0 0;
|
|
40
45
|
opacity: 1;
|
|
41
46
|
}
|