@ultimat3/ui 22.15.0 → 24.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 +45 -14
- package/CLAUDE.md +16 -52
- package/README.md +69 -13
- package/package.json +6 -5
- package/src/a11y.ts +24 -10
- package/src/catalog/parse-component.ts +85 -12
- package/src/catalog/render-catalog.ts +21 -1
- package/src/components/Avatar.module.scss +1 -1
- package/src/components/BarChart.module.scss +12 -1
- package/src/components/BarChart.tsx +41 -15
- package/src/components/Button.tsx +3 -8
- package/src/components/Card.module.scss +1 -1
- package/src/components/Checkbox.module.scss +4 -4
- package/src/components/Checkbox.tsx +6 -0
- package/src/components/CommandPalette.module.scss +8 -5
- package/src/components/DataTable.module.scss +2 -0
- package/src/components/DataTable.tsx +135 -71
- package/src/components/Dialog.module.scss +2 -2
- package/src/components/Dropzone.module.scss +6 -4
- package/src/components/Dropzone.tsx +9 -8
- package/src/components/FileInput.module.scss +4 -2
- package/src/components/FileInput.tsx +6 -2
- package/src/components/Form.module.scss +2 -2
- package/src/components/Input.module.scss +1 -1
- package/src/components/Link.tsx +64 -12
- package/src/components/Meter.tsx +3 -3
- package/src/components/Pagination.tsx +89 -37
- package/src/components/Popover.module.scss +17 -20
- package/src/components/Popover.tsx +12 -5
- package/src/components/Radio.module.scss +2 -2
- package/src/components/Select.module.scss +3 -3
- package/src/components/Skeleton.module.scss +1 -1
- package/src/components/Spinner.module.scss +3 -3
- package/src/components/Switch.module.scss +2 -2
- package/src/components/Table.module.scss +2 -2
- package/src/components/Tabs.module.scss +11 -5
- package/src/components/Textarea.tsx +7 -4
- package/src/components/ThemeToggle.module.scss +1 -1
- package/src/components/Toast.module.scss +2 -2
- package/src/components/Tooltip.module.scss +8 -3
- package/src/components/bar-chart-view.ts +35 -5
- package/src/components/button-classes.ts +25 -0
- package/src/components/file-input-view.ts +31 -12
- package/src/components/link-classes.ts +38 -0
- package/src/components/link-target.ts +34 -1
- package/src/components/meter-view.ts +13 -0
- package/src/components/qr-matrix.ts +16 -23
- package/src/components/qr-reader.ts +187 -0
- package/src/components/relative-time-view.ts +11 -4
- package/src/form/form-binding.ts +9 -8
- package/src/icons/build-icons.ts +96 -20
- package/src/index.ts +15 -9
- package/src/sass-probe.ts +55 -0
- package/src/theme/brand.ts +22 -3
- package/src/theme/theme.ts +35 -10
- package/src/toast/toast-state.ts +0 -0
- package/src/tokens/_index.scss +3 -1
- package/src/tokens/_mixins.scss +56 -8
- package/src/tokens/_motion.scss +4 -0
- package/src/tokens/_stroke.scss +20 -0
- package/src/tokens/_units.scss +47 -0
- package/src/tokens/reset.scss +4 -4
- package/src/tokens/theme.scss +2 -0
- package/src/tokens/tokens.ts +6 -0
- package/src/theme/inline-script.ts +0 -49
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
@keyframes ultimate-dialog-in {
|
|
4
4
|
from {
|
|
5
5
|
opacity: 0;
|
|
6
|
-
translate: 0
|
|
6
|
+
translate: 0 t.space(2);
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
9
|
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
animation: ultimate-dialog-in t.duration(base) t.easing(out);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
@include t.scrim-backdrop($blur: 2px);
|
|
25
|
+
@include t.scrim-backdrop($blur: t.rem(2px));
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
.size-sm {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
@include t.column(t.space(2), center);
|
|
5
5
|
@include t.transition(background-color, border-color);
|
|
6
6
|
position: relative;
|
|
7
|
-
border:
|
|
7
|
+
border: t.stroke(thick) dashed t.role('line');
|
|
8
8
|
border-radius: t.radius(lg);
|
|
9
9
|
background: t.role('surface');
|
|
10
10
|
padding-block: t.space(8);
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
// The hidden input keeps the focus, so the ring has to be drawn on the surface around it.
|
|
16
16
|
&:focus-within {
|
|
17
17
|
border-color: t.role('accent');
|
|
18
|
-
outline:
|
|
19
|
-
outline-offset:
|
|
18
|
+
outline: t.stroke(thick) solid t.role('accent');
|
|
19
|
+
outline-offset: t.stroke(thick);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
&[data-over='true'] {
|
|
@@ -25,8 +25,10 @@
|
|
|
25
25
|
background: t.role('bg-soft');
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// Dimmed directly: `@include t.disabled` appends `:disabled`, which a `<label>` carrying a data
|
|
29
|
+
// flag can never be, so the rule it compiled to matched nothing (`disabled-dim.test.ts`).
|
|
28
30
|
&[data-disabled='true'] {
|
|
29
|
-
|
|
31
|
+
opacity: 0.55;
|
|
30
32
|
background: t.role('bg-soft');
|
|
31
33
|
cursor: not-allowed;
|
|
32
34
|
}
|
|
@@ -12,7 +12,7 @@ import { useUi } from '../theme/context';
|
|
|
12
12
|
import { solid } from '../theme/solid-adapter';
|
|
13
13
|
import styles from './Dropzone.module.scss';
|
|
14
14
|
import type { FileSelection } from './file-input-view';
|
|
15
|
-
import {
|
|
15
|
+
import { adoptAcceptedFiles, progressPercent, selectFiles } from './file-input-view';
|
|
16
16
|
|
|
17
17
|
export interface DropzoneProps {
|
|
18
18
|
/** Already-translated instruction. Required — it is the control's accessible name. */
|
|
@@ -49,8 +49,12 @@ export function Dropzone(props: DropzoneProps): JSX.Element {
|
|
|
49
49
|
const inputId = (): string => props.id ?? fallbackId;
|
|
50
50
|
const percent = (): number => progressPercent(props.progress ?? 0);
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// The input first, then the callback: a drop that only reaches `onSelect` leaves the real
|
|
53
|
+
// control empty, and a refused file left in it is posted by the form anyway.
|
|
54
|
+
const offer = (files: FileList | null | undefined, origin: 'drop' | 'pick'): void => {
|
|
55
|
+
const selection = selectFiles([...(files ?? [])], props);
|
|
56
|
+
adoptAcceptedFiles(input, files, selection, origin);
|
|
57
|
+
props.onSelect(selection);
|
|
54
58
|
};
|
|
55
59
|
|
|
56
60
|
// `dragover` must be cancelled on every tick or the browser refuses the drop and navigates to
|
|
@@ -74,10 +78,7 @@ export function Dropzone(props: DropzoneProps): JSX.Element {
|
|
|
74
78
|
event.preventDefault();
|
|
75
79
|
setOver(false);
|
|
76
80
|
if (props.disabled === true) return;
|
|
77
|
-
|
|
78
|
-
// control empty, and the form the label sits in submits nothing.
|
|
79
|
-
adoptDroppedFiles(input, event.dataTransfer?.files);
|
|
80
|
-
offer(event.dataTransfer?.files);
|
|
81
|
+
offer(event.dataTransfer?.files, 'drop');
|
|
81
82
|
}}
|
|
82
83
|
>
|
|
83
84
|
<span class={styles['label']}>{props.label}</span>
|
|
@@ -96,7 +97,7 @@ export function Dropzone(props: DropzoneProps): JSX.Element {
|
|
|
96
97
|
disabled={props.disabled === true}
|
|
97
98
|
aria-describedby={props['aria-describedby']}
|
|
98
99
|
aria-invalid={ariaBool(props['aria-invalid'])}
|
|
99
|
-
onChange={(event) => offer(event.currentTarget.files)}
|
|
100
|
+
onChange={(event) => offer(event.currentTarget.files, 'pick')}
|
|
100
101
|
/>
|
|
101
102
|
{props.progress === undefined ? null : (
|
|
102
103
|
<span
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
&:focus-within {
|
|
10
10
|
border-color: t.role('accent');
|
|
11
|
-
outline:
|
|
11
|
+
outline: t.stroke(thick) solid t.role('accent');
|
|
12
12
|
outline-offset: 1px;
|
|
13
13
|
}
|
|
14
14
|
|
|
@@ -51,8 +51,10 @@
|
|
|
51
51
|
border-color: t.role('accent');
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// Dimmed directly: a pseudo-element is never itself `:disabled`, so `@include t.disabled` here
|
|
55
|
+
// compiled to a selector nothing matches (`disabled-dim.test.ts`).
|
|
54
56
|
&:disabled::file-selector-button {
|
|
55
|
-
|
|
57
|
+
opacity: 0.55;
|
|
56
58
|
cursor: not-allowed;
|
|
57
59
|
}
|
|
58
60
|
}
|
|
@@ -10,7 +10,7 @@ import { UI_KEYS } from '../i18n-keys';
|
|
|
10
10
|
import { useUi } from '../theme/context';
|
|
11
11
|
import styles from './FileInput.module.scss';
|
|
12
12
|
import type { FileSelection } from './file-input-view';
|
|
13
|
-
import { progressPercent, selectFiles } from './file-input-view';
|
|
13
|
+
import { adoptAcceptedFiles, progressPercent, selectFiles } from './file-input-view';
|
|
14
14
|
import type { Size } from './variants';
|
|
15
15
|
|
|
16
16
|
export interface FileInputProps {
|
|
@@ -61,7 +61,11 @@ export function FileInput(props: FileInputProps): JSX.Element {
|
|
|
61
61
|
aria-invalid={ariaBool(props['aria-invalid'])}
|
|
62
62
|
onBlur={props.onBlur}
|
|
63
63
|
onChange={(event) => {
|
|
64
|
-
|
|
64
|
+
const selection = selectFiles([...(event.currentTarget.files ?? [])], props);
|
|
65
|
+
// `accept` only steers the picker; "All files" gets past it, and the form would post
|
|
66
|
+
// what this control just refused.
|
|
67
|
+
adoptAcceptedFiles(event.currentTarget, event.currentTarget.files, selection, 'pick');
|
|
68
|
+
props.onSelect?.(selection);
|
|
65
69
|
}}
|
|
66
70
|
/>
|
|
67
71
|
{props.progress === undefined ? null : (
|
package/src/components/Link.tsx
CHANGED
|
@@ -1,25 +1,62 @@
|
|
|
1
1
|
// Anchor primitive. External links get `rel` hardening and a translated
|
|
2
2
|
// "opens in a new tab" hint automatically — never a bare `target="_blank"`.
|
|
3
|
+
// `appearance="button"` is Button's look on the same anchor: a control that navigates is a link.
|
|
3
4
|
|
|
4
5
|
import type { JSX } from 'solid-js';
|
|
5
6
|
import { cx } from '../cx';
|
|
7
|
+
import buttonStyles from './Button.module.scss';
|
|
6
8
|
import styles from './Link.module.scss';
|
|
7
|
-
import {
|
|
9
|
+
import { linkClasses } from './link-classes';
|
|
10
|
+
import { linkRel, linkTarget } from './link-target';
|
|
11
|
+
import type { ButtonVariant, Size, Tone } from './variants';
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
interface LinkBaseProps {
|
|
10
14
|
href: string;
|
|
11
15
|
children: JSX.Element;
|
|
12
16
|
external?: boolean | undefined;
|
|
13
17
|
/** Announced suffix for external links; supply via `t()` at the call site. */
|
|
14
18
|
externalHint?: string | undefined;
|
|
15
|
-
|
|
16
|
-
|
|
19
|
+
/** A pager's direction. Joins the hardening an external link always carries, never replaces it. */
|
|
20
|
+
rel?: 'next' | 'prev' | undefined;
|
|
17
21
|
id?: string | undefined;
|
|
18
22
|
class?: string | undefined;
|
|
19
23
|
'aria-current'?: 'page' | 'step' | 'true' | false | undefined;
|
|
20
24
|
onClick?: JSX.EventHandlerUnion<HTMLAnchorElement, MouseEvent> | undefined;
|
|
21
25
|
}
|
|
22
26
|
|
|
27
|
+
/** Inline text — the default. */
|
|
28
|
+
export interface TextLinkProps extends LinkBaseProps {
|
|
29
|
+
/** `button` wears Button's classes on the same `<a>`; the two looks take different props. */
|
|
30
|
+
appearance?: 'link' | undefined;
|
|
31
|
+
underline?: 'always' | 'hover' | 'none' | undefined;
|
|
32
|
+
/** A text link: `accent` or `inherit`. A button-link: any `Tone`. */
|
|
33
|
+
tone?: 'accent' | 'inherit' | undefined;
|
|
34
|
+
variant?: undefined;
|
|
35
|
+
size?: undefined;
|
|
36
|
+
fullWidth?: undefined;
|
|
37
|
+
iconStart?: undefined;
|
|
38
|
+
iconEnd?: undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Button's look on an anchor. There is no `href` on `<Button>`: this is that control. */
|
|
42
|
+
export interface ButtonLinkProps extends LinkBaseProps {
|
|
43
|
+
appearance: 'button';
|
|
44
|
+
/** `appearance="button"` only. */
|
|
45
|
+
variant?: ButtonVariant | undefined;
|
|
46
|
+
tone?: Tone | undefined;
|
|
47
|
+
/** `appearance="button"` only. */
|
|
48
|
+
size?: Size | undefined;
|
|
49
|
+
/** `appearance="button"` only. */
|
|
50
|
+
fullWidth?: boolean | undefined;
|
|
51
|
+
/** `appearance="button"` only. */
|
|
52
|
+
iconStart?: JSX.Element | undefined;
|
|
53
|
+
/** `appearance="button"` only. */
|
|
54
|
+
iconEnd?: JSX.Element | undefined;
|
|
55
|
+
underline?: undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type LinkProps = TextLinkProps | ButtonLinkProps;
|
|
59
|
+
|
|
23
60
|
export function Link(props: LinkProps): JSX.Element {
|
|
24
61
|
// Both the href and the external verdict come from ONE decision (`link-target.ts`): a
|
|
25
62
|
// `javascript:` href used to be emitted verbatim AND classified internal, so it lost the
|
|
@@ -28,22 +65,37 @@ export function Link(props: LinkProps): JSX.Element {
|
|
|
28
65
|
linkTarget(props.href, props.external);
|
|
29
66
|
const isExternal = (): boolean => target().external;
|
|
30
67
|
|
|
68
|
+
/**
|
|
69
|
+
* A button-link wraps its label exactly as `<Button>` does — the text truncates inside the
|
|
70
|
+
* padding, the icons sit outside the truncated run — because the same classes on a different
|
|
71
|
+
* child structure is a different button.
|
|
72
|
+
*/
|
|
73
|
+
const content = (): JSX.Element =>
|
|
74
|
+
props.appearance === 'button'
|
|
75
|
+
? [
|
|
76
|
+
props.iconStart,
|
|
77
|
+
<span class={buttonStyles['label']}>{props.children}</span>,
|
|
78
|
+
props.iconEnd,
|
|
79
|
+
]
|
|
80
|
+
: props.children;
|
|
81
|
+
|
|
82
|
+
const classes = (): string => {
|
|
83
|
+
const look = linkClasses(props);
|
|
84
|
+
const sheet = look.sheet === 'button' ? buttonStyles : styles;
|
|
85
|
+
return cx(...look.keys.map((key) => sheet[key]), props.class);
|
|
86
|
+
};
|
|
87
|
+
|
|
31
88
|
return (
|
|
32
89
|
<a
|
|
33
90
|
id={props.id}
|
|
34
91
|
href={target().href}
|
|
35
92
|
target={isExternal() ? '_blank' : undefined}
|
|
36
|
-
rel={isExternal()
|
|
93
|
+
rel={linkRel(isExternal(), props.rel)}
|
|
37
94
|
aria-current={props['aria-current']}
|
|
38
|
-
class={
|
|
39
|
-
styles['link'],
|
|
40
|
-
styles[`underline-${props.underline ?? 'hover'}`],
|
|
41
|
-
styles[`tone-${props.tone ?? 'accent'}`],
|
|
42
|
-
props.class,
|
|
43
|
-
)}
|
|
95
|
+
class={classes()}
|
|
44
96
|
onClick={props.onClick}
|
|
45
97
|
>
|
|
46
|
-
{
|
|
98
|
+
{content()}
|
|
47
99
|
{isExternal() && props.externalHint !== undefined ? (
|
|
48
100
|
<span class={styles['hint']}>{props.externalHint}</span>
|
|
49
101
|
) : null}
|
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>
|
|
@@ -1,32 +1,63 @@
|
|
|
1
1
|
// Cursor-first pagination — the only shape a Postgres-backed list should use.
|
|
2
2
|
// Numbered mode exists for prerendered archives where the total is known.
|
|
3
|
+
// One mode per use: callbacks (`onCursor` / `onPage`) for an island, or `hrefFor` for a
|
|
4
|
+
// server-rendered list — anchors the browser follows, with no script on the page.
|
|
3
5
|
|
|
4
6
|
import type { JSX } from 'solid-js';
|
|
5
7
|
import { cx } from '../cx';
|
|
6
8
|
import { UI_KEYS } from '../i18n-keys';
|
|
7
9
|
import { useUi } from '../theme/context';
|
|
8
10
|
import { Button } from './Button';
|
|
11
|
+
import { Link } from './Link';
|
|
9
12
|
import styles from './Pagination.module.scss';
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
type PageDirection = 'next' | 'prev';
|
|
15
|
+
|
|
16
|
+
interface PaginationBaseProps {
|
|
12
17
|
/** Opaque cursor for the following page; absent disables "next". */
|
|
13
18
|
nextCursor?: string | undefined;
|
|
14
19
|
prevCursor?: string | undefined;
|
|
20
|
+
/** Already-translated; falls back to the ui.* catalog keys. */
|
|
21
|
+
labelPrevious?: string | undefined;
|
|
22
|
+
labelNext?: string | undefined;
|
|
23
|
+
class?: string | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Callback mode: the page is an island and pages itself. */
|
|
27
|
+
export interface PaginationCallbackProps extends PaginationBaseProps {
|
|
28
|
+
/** Callback mode. Never beside `hrefFor` — the type refuses the pair. */
|
|
15
29
|
onCursor?: ((cursor: string, direction: 'next' | 'prev') => void) | undefined;
|
|
16
30
|
/** Numbered mode: 1-based page and total. Ignored when cursors are present. */
|
|
17
31
|
page?: number | undefined;
|
|
18
32
|
totalPages?: number | undefined;
|
|
19
33
|
onPage?: ((page: number) => void) | undefined;
|
|
20
|
-
|
|
21
|
-
labelPrevious?: string | undefined;
|
|
22
|
-
labelNext?: string | undefined;
|
|
23
|
-
class?: string | undefined;
|
|
34
|
+
hrefFor?: undefined;
|
|
24
35
|
}
|
|
25
36
|
|
|
37
|
+
/** Link mode: each cursor is a URL, so the pager works with scripting off. */
|
|
38
|
+
export interface PaginationLinkProps extends PaginationBaseProps {
|
|
39
|
+
/**
|
|
40
|
+
* Link mode: the URL of the page a cursor names. Renders `<a rel="next|prev">` and attaches no
|
|
41
|
+
* handler, so it needs no island. Cursor paging only — never beside `onCursor`, `onPage`,
|
|
42
|
+
* `page` or `totalPages`.
|
|
43
|
+
*/
|
|
44
|
+
hrefFor: (cursor: string, direction: 'next' | 'prev') => string;
|
|
45
|
+
onCursor?: undefined;
|
|
46
|
+
page?: undefined;
|
|
47
|
+
totalPages?: undefined;
|
|
48
|
+
onPage?: undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type PaginationProps = PaginationCallbackProps | PaginationLinkProps;
|
|
52
|
+
|
|
26
53
|
export function Pagination(props: PaginationProps): JSX.Element {
|
|
27
54
|
const ui = useUi();
|
|
28
|
-
const
|
|
29
|
-
|
|
55
|
+
const label = (direction: PageDirection): string =>
|
|
56
|
+
direction === 'prev'
|
|
57
|
+
? (props.labelPrevious ?? ui.t(UI_KEYS.previous))
|
|
58
|
+
: (props.labelNext ?? ui.t(UI_KEYS.next));
|
|
59
|
+
const cursor = (direction: PageDirection): string | undefined =>
|
|
60
|
+
direction === 'prev' ? props.prevCursor : props.nextCursor;
|
|
30
61
|
// A cursor present means cursor mode, exactly as `page`'s own doc says. The inverted form —
|
|
31
62
|
// "cursor mode when a number is MISSING" — silently dropped the cursor and paged by number the
|
|
32
63
|
// moment a caller passed both, which is the shape a list that knows its total naturally has.
|
|
@@ -36,23 +67,62 @@ export function Pagination(props: PaginationProps): JSX.Element {
|
|
|
36
67
|
props.page === undefined ||
|
|
37
68
|
props.totalPages === undefined;
|
|
38
69
|
|
|
39
|
-
|
|
40
|
-
|
|
70
|
+
const exhausted = (direction: PageDirection): boolean => {
|
|
71
|
+
if (cursorMode()) return cursor(direction) === undefined;
|
|
72
|
+
return direction === 'prev'
|
|
73
|
+
? (props.page ?? 1) <= 1
|
|
74
|
+
: (props.page ?? 1) >= (props.totalPages ?? 1);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const go = (direction: PageDirection): void => {
|
|
78
|
+
const to = cursor(direction);
|
|
79
|
+
if (cursorMode()) {
|
|
80
|
+
if (to !== undefined) props.onCursor?.(to, direction);
|
|
81
|
+
} else if (direction === 'prev') {
|
|
82
|
+
props.onPage?.(Math.max(1, (props.page ?? 1) - 1));
|
|
83
|
+
} else {
|
|
84
|
+
props.onPage?.(Math.min(props.totalPages ?? 1, (props.page ?? 1) + 1));
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* One side of the pager. In link mode a side WITH a cursor is an anchor and a side without one
|
|
90
|
+
* is the same disabled button callback mode shows: an anchor with nowhere to go is a dead
|
|
91
|
+
* control that still takes a tab stop, and dropping the side would move the other one between
|
|
92
|
+
* the first page and the second.
|
|
93
|
+
*/
|
|
94
|
+
const side = (direction: PageDirection): JSX.Element => {
|
|
95
|
+
const to = cursor(direction);
|
|
96
|
+
if (props.hrefFor !== undefined && to !== undefined) {
|
|
97
|
+
return (
|
|
98
|
+
<Link
|
|
99
|
+
appearance="button"
|
|
100
|
+
variant="secondary"
|
|
101
|
+
size="sm"
|
|
102
|
+
tone="neutral"
|
|
103
|
+
rel={direction}
|
|
104
|
+
href={props.hrefFor(to, direction)}
|
|
105
|
+
>
|
|
106
|
+
{label(direction)}
|
|
107
|
+
</Link>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return (
|
|
41
111
|
<Button
|
|
42
112
|
variant="secondary"
|
|
43
113
|
size="sm"
|
|
44
114
|
tone="neutral"
|
|
45
|
-
disabled={
|
|
46
|
-
onClick={() =>
|
|
47
|
-
if (cursorMode()) {
|
|
48
|
-
if (props.prevCursor !== undefined) props.onCursor?.(props.prevCursor, 'prev');
|
|
49
|
-
} else {
|
|
50
|
-
props.onPage?.(Math.max(1, (props.page ?? 1) - 1));
|
|
51
|
-
}
|
|
52
|
-
}}
|
|
115
|
+
disabled={exhausted(direction)}
|
|
116
|
+
onClick={props.hrefFor === undefined ? () => go(direction) : undefined}
|
|
53
117
|
>
|
|
54
|
-
{
|
|
118
|
+
{label(direction)}
|
|
55
119
|
</Button>
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<nav class={cx(styles['pagination'], props.class)} aria-label={ui.t(UI_KEYS.page)}>
|
|
125
|
+
{side('prev')}
|
|
56
126
|
|
|
57
127
|
{cursorMode() ? null : (
|
|
58
128
|
<span class={styles['status']} aria-live="polite">
|
|
@@ -60,25 +130,7 @@ export function Pagination(props: PaginationProps): JSX.Element {
|
|
|
60
130
|
</span>
|
|
61
131
|
)}
|
|
62
132
|
|
|
63
|
-
|
|
64
|
-
variant="secondary"
|
|
65
|
-
size="sm"
|
|
66
|
-
tone="neutral"
|
|
67
|
-
disabled={
|
|
68
|
-
cursorMode()
|
|
69
|
-
? props.nextCursor === undefined
|
|
70
|
-
: (props.page ?? 1) >= (props.totalPages ?? 1)
|
|
71
|
-
}
|
|
72
|
-
onClick={() => {
|
|
73
|
-
if (cursorMode()) {
|
|
74
|
-
if (props.nextCursor !== undefined) props.onCursor?.(props.nextCursor, 'next');
|
|
75
|
-
} else {
|
|
76
|
-
props.onPage?.(Math.min(props.totalPages ?? 1, (props.page ?? 1) + 1));
|
|
77
|
-
}
|
|
78
|
-
}}
|
|
79
|
-
>
|
|
80
|
-
{next()}
|
|
81
|
-
</Button>
|
|
133
|
+
{side('next')}
|
|
82
134
|
</nav>
|
|
83
135
|
);
|
|
84
136
|
}
|
|
@@ -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,6 +1,6 @@
|
|
|
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
6
|
import { createFocusTrap, useId } from '../a11y';
|
|
@@ -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. */
|
|
@@ -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) => {
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
inset-inline-end: t.space(3);
|
|
31
31
|
inline-size: 0.4rem;
|
|
32
32
|
block-size: 0.4rem;
|
|
33
|
-
border-inline-end:
|
|
34
|
-
border-block-end:
|
|
35
|
-
transform: translateY(-2px) rotate(45deg);
|
|
33
|
+
border-inline-end: t.stroke(thick) solid t.role('fg-muted');
|
|
34
|
+
border-block-end: t.stroke(thick) solid t.role('fg-muted');
|
|
35
|
+
transform: translateY(t.rem(-2px)) rotate(45deg);
|
|
36
36
|
pointer-events: none;
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
.spinner {
|
|
10
10
|
display: inline-block;
|
|
11
|
-
border:
|
|
11
|
+
border: t.stroke(thick) solid t.role('line');
|
|
12
12
|
border-block-start-color: t.role('accent');
|
|
13
13
|
border-radius: t.radius(full);
|
|
14
|
-
animation: ultimate-spin
|
|
14
|
+
animation: ultimate-spin t.duration(slower) linear infinite;
|
|
15
15
|
// Reduced motion is handled globally in reset.scss; the ring still reads as
|
|
16
16
|
// a distinct shape when the animation is neutralised.
|
|
17
17
|
}
|
|
@@ -29,5 +29,5 @@
|
|
|
29
29
|
.size-lg {
|
|
30
30
|
inline-size: 2rem;
|
|
31
31
|
block-size: 2rem;
|
|
32
|
-
border-width:
|
|
32
|
+
border-width: t.stroke(heavy);
|
|
33
33
|
}
|