@ultimat3/ui 1.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/LICENSE +21 -0
- package/README.md +108 -0
- package/package.json +45 -0
- package/src/a11y.ts +187 -0
- package/src/components/Alert.module.scss +70 -0
- package/src/components/Alert.tsx +49 -0
- package/src/components/Avatar.module.scss +66 -0
- package/src/components/Avatar.tsx +56 -0
- package/src/components/Badge.module.scss +94 -0
- package/src/components/Badge.tsx +34 -0
- package/src/components/Breadcrumb.module.scss +45 -0
- package/src/components/Breadcrumb.tsx +48 -0
- package/src/components/Button.module.scss +146 -0
- package/src/components/Button.tsx +66 -0
- package/src/components/Card.module.scss +69 -0
- package/src/components/Card.tsx +40 -0
- package/src/components/Checkbox.module.scss +90 -0
- package/src/components/Checkbox.tsx +57 -0
- package/src/components/Container.module.scss +31 -0
- package/src/components/Container.tsx +29 -0
- package/src/components/DataTable.module.scss +33 -0
- package/src/components/DataTable.tsx +149 -0
- package/src/components/DateTime.module.scss +9 -0
- package/src/components/DateTime.tsx +45 -0
- package/src/components/Dialog.module.scss +83 -0
- package/src/components/Dialog.tsx +75 -0
- package/src/components/Divider.module.scss +39 -0
- package/src/components/Divider.tsx +38 -0
- package/src/components/Drawer.module.scss +89 -0
- package/src/components/Drawer.tsx +76 -0
- package/src/components/EmptyState.module.scss +34 -0
- package/src/components/EmptyState.tsx +35 -0
- package/src/components/ErrorState.module.scss +77 -0
- package/src/components/ErrorState.tsx +84 -0
- package/src/components/Field.module.scss +41 -0
- package/src/components/Field.tsx +86 -0
- package/src/components/Form.module.scss +21 -0
- package/src/components/Form.tsx +51 -0
- package/src/components/Grid.module.scss +9 -0
- package/src/components/Grid.tsx +40 -0
- package/src/components/IconButton.module.scss +99 -0
- package/src/components/IconButton.tsx +53 -0
- package/src/components/Image.module.scss +14 -0
- package/src/components/Image.tsx +59 -0
- package/src/components/Input.module.scss +55 -0
- package/src/components/Input.tsx +84 -0
- package/src/components/Link.module.scss +42 -0
- package/src/components/Link.tsx +46 -0
- package/src/components/LocaleSwitcher.module.scss +28 -0
- package/src/components/LocaleSwitcher.tsx +61 -0
- package/src/components/Menu.module.scss +63 -0
- package/src/components/Menu.tsx +115 -0
- package/src/components/Money.module.scss +11 -0
- package/src/components/Money.tsx +49 -0
- package/src/components/Pagination.module.scss +15 -0
- package/src/components/Pagination.tsx +77 -0
- package/src/components/Popover.module.scss +59 -0
- package/src/components/Popover.tsx +76 -0
- package/src/components/Radio.module.scss +87 -0
- package/src/components/Radio.tsx +64 -0
- package/src/components/RelativeTime.module.scss +7 -0
- package/src/components/RelativeTime.tsx +49 -0
- package/src/components/Select.module.scss +51 -0
- package/src/components/Select.tsx +63 -0
- package/src/components/Skeleton.module.scss +41 -0
- package/src/components/Skeleton.tsx +34 -0
- package/src/components/Spinner.module.scss +33 -0
- package/src/components/Spinner.tsx +35 -0
- package/src/components/Stack.module.scss +21 -0
- package/src/components/Stack.tsx +49 -0
- package/src/components/Switch.module.scss +66 -0
- package/src/components/Switch.tsx +44 -0
- package/src/components/Table.module.scss +77 -0
- package/src/components/Table.tsx +42 -0
- package/src/components/Tabs.module.scss +71 -0
- package/src/components/Tabs.tsx +88 -0
- package/src/components/Text.module.scss +37 -0
- package/src/components/Text.tsx +57 -0
- package/src/components/Textarea.module.scss +15 -0
- package/src/components/Textarea.tsx +48 -0
- package/src/components/ThemeToggle.module.scss +23 -0
- package/src/components/ThemeToggle.tsx +93 -0
- package/src/components/Toast.module.scss +92 -0
- package/src/components/Toast.tsx +74 -0
- package/src/components/Tooltip.module.scss +41 -0
- package/src/components/Tooltip.tsx +32 -0
- package/src/components/date-time-view.ts +68 -0
- package/src/components/image-source.ts +150 -0
- package/src/components/money-view.ts +50 -0
- package/src/components/relative-time-view.ts +45 -0
- package/src/components/sort-state.ts +28 -0
- package/src/components/variants.ts +17 -0
- package/src/cx.ts +42 -0
- package/src/errors.ts +72 -0
- package/src/global.scss +5 -0
- package/src/i18n-keys.ts +34 -0
- package/src/index.ts +199 -0
- package/src/scss.d.ts +7 -0
- package/src/theme/context.ts +65 -0
- package/src/theme/inline-script.ts +44 -0
- package/src/theme/provider.tsx +56 -0
- package/src/theme/solid-adapter.ts +51 -0
- package/src/theme/theme.ts +116 -0
- package/src/tokens/_breakpoints.scss +10 -0
- package/src/tokens/_colors.scss +95 -0
- package/src/tokens/_index.scss +13 -0
- package/src/tokens/_mixins.scss +186 -0
- package/src/tokens/_motion.scss +36 -0
- package/src/tokens/_radius.scss +23 -0
- package/src/tokens/_shadow.scss +49 -0
- package/src/tokens/_space.scss +28 -0
- package/src/tokens/_typography.scss +83 -0
- package/src/tokens/_z.scss +27 -0
- package/src/tokens/reset.scss +144 -0
- package/src/tokens/theme.scss +19 -0
- package/src/tokens/tokens.ts +222 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.pagination {
|
|
4
|
+
display: flex;
|
|
5
|
+
align-items: center;
|
|
6
|
+
gap: t.space(2);
|
|
7
|
+
// Trailing edge in both writing directions.
|
|
8
|
+
justify-content: flex-end;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.status {
|
|
12
|
+
color: t.role('fg-muted');
|
|
13
|
+
font-size: t.text(sm);
|
|
14
|
+
font-variant-numeric: tabular-nums;
|
|
15
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Cursor-first pagination — the only shape a Postgres-backed list should use.
|
|
2
|
+
// Numbered mode exists for prerendered archives where the total is known.
|
|
3
|
+
|
|
4
|
+
import type { JSX } from 'solid-js';
|
|
5
|
+
import { cx } from '../cx';
|
|
6
|
+
import { UI_KEYS } from '../i18n-keys';
|
|
7
|
+
import { useUi } from '../theme/context';
|
|
8
|
+
import { Button } from './Button';
|
|
9
|
+
import styles from './Pagination.module.scss';
|
|
10
|
+
|
|
11
|
+
export interface PaginationProps {
|
|
12
|
+
/** Opaque cursor for the following page; absent disables "next". */
|
|
13
|
+
nextCursor?: string | undefined;
|
|
14
|
+
prevCursor?: string | undefined;
|
|
15
|
+
onCursor?: ((cursor: string, direction: 'next' | 'prev') => void) | undefined;
|
|
16
|
+
/** Numbered mode: 1-based page and total. Ignored when cursors are present. */
|
|
17
|
+
page?: number | undefined;
|
|
18
|
+
totalPages?: number | undefined;
|
|
19
|
+
onPage?: ((page: number) => void) | 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
|
+
export function Pagination(props: PaginationProps): JSX.Element {
|
|
27
|
+
const ui = useUi();
|
|
28
|
+
const previous = (): string => props.labelPrevious ?? ui.t(UI_KEYS.previous);
|
|
29
|
+
const next = (): string => props.labelNext ?? ui.t(UI_KEYS.next);
|
|
30
|
+
const cursorMode = (): boolean => props.page === undefined || props.totalPages === undefined;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<nav class={cx(styles['pagination'], props.class)} aria-label={ui.t(UI_KEYS.page)}>
|
|
34
|
+
<Button
|
|
35
|
+
variant="secondary"
|
|
36
|
+
size="sm"
|
|
37
|
+
tone="neutral"
|
|
38
|
+
disabled={cursorMode() ? props.prevCursor === undefined : (props.page ?? 1) <= 1}
|
|
39
|
+
onClick={() => {
|
|
40
|
+
if (cursorMode()) {
|
|
41
|
+
if (props.prevCursor !== undefined) props.onCursor?.(props.prevCursor, 'prev');
|
|
42
|
+
} else {
|
|
43
|
+
props.onPage?.(Math.max(1, (props.page ?? 1) - 1));
|
|
44
|
+
}
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
{previous()}
|
|
48
|
+
</Button>
|
|
49
|
+
|
|
50
|
+
{cursorMode() ? null : (
|
|
51
|
+
<span class={styles['status']} aria-live="polite">
|
|
52
|
+
{`${props.page ?? 1} / ${props.totalPages ?? 1}`}
|
|
53
|
+
</span>
|
|
54
|
+
)}
|
|
55
|
+
|
|
56
|
+
<Button
|
|
57
|
+
variant="secondary"
|
|
58
|
+
size="sm"
|
|
59
|
+
tone="neutral"
|
|
60
|
+
disabled={
|
|
61
|
+
cursorMode()
|
|
62
|
+
? props.nextCursor === undefined
|
|
63
|
+
: (props.page ?? 1) >= (props.totalPages ?? 1)
|
|
64
|
+
}
|
|
65
|
+
onClick={() => {
|
|
66
|
+
if (cursorMode()) {
|
|
67
|
+
if (props.nextCursor !== undefined) props.onCursor?.(props.nextCursor, 'next');
|
|
68
|
+
} else {
|
|
69
|
+
props.onPage?.(Math.min(props.totalPages ?? 1, (props.page ?? 1) + 1));
|
|
70
|
+
}
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
{next()}
|
|
74
|
+
</Button>
|
|
75
|
+
</nav>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.anchor {
|
|
4
|
+
position: relative;
|
|
5
|
+
display: inline-block;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.panel {
|
|
9
|
+
@include t.surface('surface-raised', 'lg');
|
|
10
|
+
position: absolute;
|
|
11
|
+
z-index: t.z(popover);
|
|
12
|
+
min-inline-size: 12rem;
|
|
13
|
+
max-inline-size: min(24rem, 90vw);
|
|
14
|
+
padding: t.space(4);
|
|
15
|
+
color: t.role('fg');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.placement-block-end {
|
|
19
|
+
inset-block-start: calc(100% + #{t.space(2)});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.placement-block-start {
|
|
23
|
+
inset-block-end: calc(100% + #{t.space(2)});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.placement-inline-end {
|
|
27
|
+
inset-inline-start: calc(100% + #{t.space(2)});
|
|
28
|
+
inset-block-start: 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.placement-inline-start {
|
|
32
|
+
inset-inline-end: calc(100% + #{t.space(2)});
|
|
33
|
+
inset-block-start: 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.align-start {
|
|
37
|
+
inset-inline-start: 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.align-end {
|
|
41
|
+
inset-inline-end: 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.align-center {
|
|
45
|
+
inset-inline-start: 50%;
|
|
46
|
+
translate: -50% 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
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
|
+
&.align-end {
|
|
55
|
+
inset-inline-start: auto;
|
|
56
|
+
inset-inline-end: auto;
|
|
57
|
+
translate: none;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Non-modal anchored panel. Positioning is CSS-only (a relatively positioned
|
|
2
|
+
// anchor plus logical insets), so there is no measure/reflow loop and the panel
|
|
3
|
+
// flips sides automatically under `dir="rtl"`.
|
|
4
|
+
|
|
5
|
+
import type { JSX } from 'solid-js';
|
|
6
|
+
import { useId } from '../a11y';
|
|
7
|
+
import { cx } from '../cx';
|
|
8
|
+
import { solid } from '../theme/solid-adapter';
|
|
9
|
+
import styles from './Popover.module.scss';
|
|
10
|
+
|
|
11
|
+
export type Placement = 'block-end' | 'block-start' | 'inline-end' | 'inline-start';
|
|
12
|
+
|
|
13
|
+
export interface PopoverProps {
|
|
14
|
+
open: boolean;
|
|
15
|
+
onOpenChange: (open: boolean) => void;
|
|
16
|
+
/** The anchor. Wire its onClick to `onOpenChange` in the caller. */
|
|
17
|
+
trigger: (control: {
|
|
18
|
+
id: string;
|
|
19
|
+
'aria-expanded': boolean;
|
|
20
|
+
'aria-controls': string;
|
|
21
|
+
}) => JSX.Element;
|
|
22
|
+
children: JSX.Element;
|
|
23
|
+
/** Already-translated accessible name for the panel. */
|
|
24
|
+
label: string;
|
|
25
|
+
placement?: Placement | undefined;
|
|
26
|
+
align?: 'start' | 'center' | 'end' | undefined;
|
|
27
|
+
class?: string | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function Popover(props: PopoverProps): JSX.Element {
|
|
31
|
+
const rt = solid();
|
|
32
|
+
const panelId = useId('popover');
|
|
33
|
+
const triggerId = `${panelId}-trigger`;
|
|
34
|
+
let root: HTMLDivElement | undefined;
|
|
35
|
+
|
|
36
|
+
rt.createEffect(() => {
|
|
37
|
+
if (!props.open || typeof document === 'undefined') return;
|
|
38
|
+
const onPointerDown = (event: Event): void => {
|
|
39
|
+
if (root !== undefined && !root.contains(event.target as Node)) props.onOpenChange(false);
|
|
40
|
+
};
|
|
41
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
42
|
+
if (event.key === 'Escape') props.onOpenChange(false);
|
|
43
|
+
};
|
|
44
|
+
document.addEventListener('pointerdown', onPointerDown, true);
|
|
45
|
+
document.addEventListener('keydown', onKeyDown);
|
|
46
|
+
rt.onCleanup(() => {
|
|
47
|
+
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
48
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div
|
|
54
|
+
class={cx(styles['anchor'], props.class)}
|
|
55
|
+
ref={(el: HTMLDivElement) => {
|
|
56
|
+
root = el;
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
{props.trigger({ id: triggerId, 'aria-expanded': props.open, 'aria-controls': panelId })}
|
|
60
|
+
{props.open ? (
|
|
61
|
+
<div
|
|
62
|
+
id={panelId}
|
|
63
|
+
role="dialog"
|
|
64
|
+
aria-label={props.label}
|
|
65
|
+
class={cx(
|
|
66
|
+
styles['panel'],
|
|
67
|
+
styles[`placement-${props.placement ?? 'block-end'}`],
|
|
68
|
+
styles[`align-${props.align ?? 'start'}`],
|
|
69
|
+
)}
|
|
70
|
+
>
|
|
71
|
+
{props.children}
|
|
72
|
+
</div>
|
|
73
|
+
) : null}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.group {
|
|
4
|
+
display: flex;
|
|
5
|
+
gap: t.space(3);
|
|
6
|
+
border: 0;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.direction-row {
|
|
10
|
+
flex-flow: row wrap;
|
|
11
|
+
align-items: flex-start;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.direction-column {
|
|
15
|
+
flex-direction: column;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.legend {
|
|
19
|
+
color: t.role('fg-strong');
|
|
20
|
+
font-size: t.text(sm);
|
|
21
|
+
font-weight: t.weight(medium);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.row {
|
|
25
|
+
display: inline-flex;
|
|
26
|
+
align-items: flex-start;
|
|
27
|
+
gap: t.space(2);
|
|
28
|
+
cursor: pointer;
|
|
29
|
+
|
|
30
|
+
&:has(:disabled) {
|
|
31
|
+
color: t.role('fg-muted');
|
|
32
|
+
cursor: not-allowed;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.input {
|
|
37
|
+
@include t.visually-hidden;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.dot {
|
|
41
|
+
@include t.transition(border-color, box-shadow);
|
|
42
|
+
position: relative;
|
|
43
|
+
flex: none;
|
|
44
|
+
inline-size: 1.05rem;
|
|
45
|
+
block-size: 1.05rem;
|
|
46
|
+
margin-block-start: 0.15rem;
|
|
47
|
+
border: 1px solid t.role('line');
|
|
48
|
+
border-radius: t.radius(full);
|
|
49
|
+
background: t.role('surface-raised');
|
|
50
|
+
|
|
51
|
+
&::after {
|
|
52
|
+
position: absolute;
|
|
53
|
+
inset: 0.24rem;
|
|
54
|
+
border-radius: t.radius(full);
|
|
55
|
+
background: t.role('accent');
|
|
56
|
+
transform: scale(0);
|
|
57
|
+
content: '';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.input:checked + .dot {
|
|
62
|
+
border-color: t.role('accent');
|
|
63
|
+
|
|
64
|
+
&::after {
|
|
65
|
+
transform: scale(1);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.input:focus-visible + .dot {
|
|
70
|
+
outline: 2px solid t.role('accent');
|
|
71
|
+
outline-offset: 2px;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.text {
|
|
75
|
+
display: flex;
|
|
76
|
+
flex-direction: column;
|
|
77
|
+
gap: 0.1rem;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.label {
|
|
81
|
+
font-size: t.text(sm);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.description {
|
|
85
|
+
color: t.role('fg-muted');
|
|
86
|
+
font-size: t.text(xs);
|
|
87
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Radio group. Rendered as a <fieldset>/<legend> so the group has a name, and as
|
|
2
|
+
// one native radio per option so arrow-key navigation is the platform's job.
|
|
3
|
+
|
|
4
|
+
import type { JSX } from 'solid-js';
|
|
5
|
+
import { useId } from '../a11y';
|
|
6
|
+
import { cx } from '../cx';
|
|
7
|
+
import styles from './Radio.module.scss';
|
|
8
|
+
|
|
9
|
+
export interface RadioOption {
|
|
10
|
+
value: string;
|
|
11
|
+
/** Already-translated. */
|
|
12
|
+
label: string;
|
|
13
|
+
description?: string | undefined;
|
|
14
|
+
disabled?: boolean | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RadioProps {
|
|
18
|
+
/** Already-translated group label, rendered as the <legend>. */
|
|
19
|
+
legend: string;
|
|
20
|
+
name: string;
|
|
21
|
+
options: readonly RadioOption[];
|
|
22
|
+
value?: string | undefined;
|
|
23
|
+
disabled?: boolean | undefined;
|
|
24
|
+
required?: boolean | undefined;
|
|
25
|
+
direction?: 'row' | 'column' | undefined;
|
|
26
|
+
class?: string | undefined;
|
|
27
|
+
'aria-describedby'?: string | undefined;
|
|
28
|
+
onChange?: JSX.EventHandlerUnion<HTMLInputElement, Event> | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function Radio(props: RadioProps): JSX.Element {
|
|
32
|
+
const group = useId('radio');
|
|
33
|
+
return (
|
|
34
|
+
<fieldset
|
|
35
|
+
class={cx(styles['group'], styles[`direction-${props.direction ?? 'column'}`], props.class)}
|
|
36
|
+
disabled={props.disabled === true}
|
|
37
|
+
aria-describedby={props['aria-describedby']}
|
|
38
|
+
>
|
|
39
|
+
<legend class={styles['legend']}>{props.legend}</legend>
|
|
40
|
+
{props.options.map((option, index) => (
|
|
41
|
+
<label class={styles['row']} for={`${group}-${index}`}>
|
|
42
|
+
<input
|
|
43
|
+
class={styles['input']}
|
|
44
|
+
id={`${group}-${index}`}
|
|
45
|
+
type="radio"
|
|
46
|
+
name={props.name}
|
|
47
|
+
value={option.value}
|
|
48
|
+
checked={props.value === option.value}
|
|
49
|
+
disabled={option.disabled === true}
|
|
50
|
+
required={props.required === true}
|
|
51
|
+
onChange={props.onChange}
|
|
52
|
+
/>
|
|
53
|
+
<span aria-hidden="true" class={styles['dot']} />
|
|
54
|
+
<span class={styles['text']}>
|
|
55
|
+
<span class={styles['label']}>{option.label}</span>
|
|
56
|
+
{option.description === undefined ? null : (
|
|
57
|
+
<span class={styles['description']}>{option.description}</span>
|
|
58
|
+
)}
|
|
59
|
+
</span>
|
|
60
|
+
</label>
|
|
61
|
+
))}
|
|
62
|
+
</fieldset>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// "3 minutes ago" with the absolute instant in both `datetime` and `title`, so
|
|
2
|
+
// hovering (or reading the DOM) always recovers the exact time.
|
|
3
|
+
|
|
4
|
+
import type { TimeZone } from '@ultimat3/time';
|
|
5
|
+
import type { JSX } from 'solid-js';
|
|
6
|
+
import { cx } from '../cx';
|
|
7
|
+
import { useUi } from '../theme/context';
|
|
8
|
+
import { type DateTimeFormatter, dateTimeView, type TimeInput } from './date-time-view';
|
|
9
|
+
import styles from './RelativeTime.module.scss';
|
|
10
|
+
import { relativeTimeText } from './relative-time-view';
|
|
11
|
+
|
|
12
|
+
export interface RelativeTimeProps {
|
|
13
|
+
value: TimeInput;
|
|
14
|
+
/** Pass explicitly for SSR so server and client render the same string. */
|
|
15
|
+
now?: TimeInput | undefined;
|
|
16
|
+
locale?: string | undefined;
|
|
17
|
+
timeZone?: TimeZone | undefined;
|
|
18
|
+
numeric?: 'always' | 'auto' | undefined;
|
|
19
|
+
format?: DateTimeFormatter | undefined;
|
|
20
|
+
class?: string | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function RelativeTime(props: RelativeTimeProps): JSX.Element {
|
|
24
|
+
const ui = useUi();
|
|
25
|
+
const absolute = (): { dateTime: string; text: string } =>
|
|
26
|
+
dateTimeView({
|
|
27
|
+
value: props.value,
|
|
28
|
+
locale: props.locale ?? ui.locale,
|
|
29
|
+
timeZone: props.timeZone ?? ui.timeZone,
|
|
30
|
+
dateStyle: 'medium',
|
|
31
|
+
timeStyle: 'short',
|
|
32
|
+
...(props.format === undefined ? {} : { format: props.format }),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<time
|
|
37
|
+
class={cx(styles['relative'], props.class)}
|
|
38
|
+
datetime={absolute().dateTime}
|
|
39
|
+
title={absolute().text}
|
|
40
|
+
>
|
|
41
|
+
{relativeTimeText({
|
|
42
|
+
value: props.value,
|
|
43
|
+
locale: props.locale ?? ui.locale,
|
|
44
|
+
...(props.now === undefined ? {} : { now: props.now }),
|
|
45
|
+
...(props.numeric === undefined ? {} : { numeric: props.numeric }),
|
|
46
|
+
})}
|
|
47
|
+
</time>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.wrap {
|
|
4
|
+
@include t.control;
|
|
5
|
+
position: relative;
|
|
6
|
+
display: inline-flex;
|
|
7
|
+
align-items: center;
|
|
8
|
+
|
|
9
|
+
&:focus-within {
|
|
10
|
+
border-color: t.role('accent');
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.select {
|
|
15
|
+
flex: 1;
|
|
16
|
+
min-inline-size: 0;
|
|
17
|
+
// Room for the chevron on the trailing side, whichever side that is.
|
|
18
|
+
padding-inline-end: t.space(5);
|
|
19
|
+
border: 0;
|
|
20
|
+
background: none;
|
|
21
|
+
color: inherit;
|
|
22
|
+
font: inherit;
|
|
23
|
+
outline: none;
|
|
24
|
+
appearance: none;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A CSS chevron avoids shipping an icon font or an inline SVG per select.
|
|
28
|
+
.chevron {
|
|
29
|
+
position: absolute;
|
|
30
|
+
inset-inline-end: t.space(3);
|
|
31
|
+
inline-size: 0.4rem;
|
|
32
|
+
block-size: 0.4rem;
|
|
33
|
+
border-inline-end: 2px solid t.role('fg-muted');
|
|
34
|
+
border-block-end: 2px solid t.role('fg-muted');
|
|
35
|
+
transform: translateY(-2px) rotate(45deg);
|
|
36
|
+
pointer-events: none;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.size-sm {
|
|
40
|
+
padding-block: t.space(1);
|
|
41
|
+
font-size: t.text(sm);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.size-md {
|
|
45
|
+
padding-block: t.space(2);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.size-lg {
|
|
49
|
+
padding-block: t.space(3);
|
|
50
|
+
font-size: t.text(lg);
|
|
51
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Native <select>. Deliberately not a custom listbox: the platform control wins
|
|
2
|
+
// on mobile, on keyboard, and with screen readers.
|
|
3
|
+
|
|
4
|
+
import type { JSX } from 'solid-js';
|
|
5
|
+
import { ariaBool } from '../a11y';
|
|
6
|
+
import { cx } from '../cx';
|
|
7
|
+
import styles from './Select.module.scss';
|
|
8
|
+
import type { Size } from './variants';
|
|
9
|
+
|
|
10
|
+
export interface SelectOption {
|
|
11
|
+
value: string;
|
|
12
|
+
/** Already-translated option text. */
|
|
13
|
+
label: string;
|
|
14
|
+
disabled?: boolean | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SelectProps {
|
|
18
|
+
options: readonly SelectOption[];
|
|
19
|
+
id?: string | undefined;
|
|
20
|
+
name?: string | undefined;
|
|
21
|
+
value?: string | undefined;
|
|
22
|
+
/** Rendered as a disabled first option, so it is never a submittable value. */
|
|
23
|
+
placeholder?: string | undefined;
|
|
24
|
+
size?: Size | undefined;
|
|
25
|
+
required?: boolean | undefined;
|
|
26
|
+
disabled?: boolean | undefined;
|
|
27
|
+
class?: string | undefined;
|
|
28
|
+
'aria-label'?: string | undefined;
|
|
29
|
+
'aria-describedby'?: string | undefined;
|
|
30
|
+
'aria-invalid'?: boolean | undefined;
|
|
31
|
+
onChange?: JSX.EventHandlerUnion<HTMLSelectElement, Event> | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function Select(props: SelectProps): JSX.Element {
|
|
35
|
+
return (
|
|
36
|
+
<span class={cx(styles['wrap'], styles[`size-${props.size ?? 'md'}`], props.class)}>
|
|
37
|
+
<select
|
|
38
|
+
class={styles['select']}
|
|
39
|
+
id={props.id}
|
|
40
|
+
name={props.name}
|
|
41
|
+
value={props.value ?? ''}
|
|
42
|
+
required={props.required === true}
|
|
43
|
+
disabled={props.disabled === true}
|
|
44
|
+
aria-label={props['aria-label']}
|
|
45
|
+
aria-describedby={props['aria-describedby']}
|
|
46
|
+
aria-invalid={ariaBool(props['aria-invalid'])}
|
|
47
|
+
onChange={props.onChange}
|
|
48
|
+
>
|
|
49
|
+
{props.placeholder === undefined ? null : (
|
|
50
|
+
<option value="" disabled>
|
|
51
|
+
{props.placeholder}
|
|
52
|
+
</option>
|
|
53
|
+
)}
|
|
54
|
+
{props.options.map((option) => (
|
|
55
|
+
<option value={option.value} disabled={option.disabled === true}>
|
|
56
|
+
{option.label}
|
|
57
|
+
</option>
|
|
58
|
+
))}
|
|
59
|
+
</select>
|
|
60
|
+
<span aria-hidden="true" class={styles['chevron']} />
|
|
61
|
+
</span>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
@keyframes ultimate-shimmer {
|
|
4
|
+
to {
|
|
5
|
+
background-position: -200% 0;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.group {
|
|
10
|
+
display: flex;
|
|
11
|
+
flex-direction: column;
|
|
12
|
+
gap: t.space(2);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.skeleton {
|
|
16
|
+
display: block;
|
|
17
|
+
inline-size: var(--skeleton-w, 100%);
|
|
18
|
+
block-size: var(--skeleton-h, 1em);
|
|
19
|
+
// The sweep is built from surface roles, so it is visible on both themes.
|
|
20
|
+
background: linear-gradient(
|
|
21
|
+
90deg,
|
|
22
|
+
t.role('bg-soft') 25%,
|
|
23
|
+
t.role('line') 37%,
|
|
24
|
+
t.role('bg-soft') 63%
|
|
25
|
+
)
|
|
26
|
+
0 0 / 200% 100%;
|
|
27
|
+
animation: ultimate-shimmer 1.4s linear infinite;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.shape-text {
|
|
31
|
+
border-radius: t.radius(sm);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.shape-block {
|
|
35
|
+
border-radius: t.radius(md);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.shape-circle {
|
|
39
|
+
border-radius: t.radius(full);
|
|
40
|
+
aspect-ratio: 1;
|
|
41
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Loading placeholder. Sized by the caller so the real content lands in the same
|
|
2
|
+
// box — a skeleton that changes size on load is just a slower layout shift.
|
|
3
|
+
|
|
4
|
+
import type { JSX } from 'solid-js';
|
|
5
|
+
import { cx } from '../cx';
|
|
6
|
+
import styles from './Skeleton.module.scss';
|
|
7
|
+
|
|
8
|
+
export interface SkeletonProps {
|
|
9
|
+
/** CSS length; must match the real content's box to keep CLS at 0. */
|
|
10
|
+
width?: string | undefined;
|
|
11
|
+
height?: string | undefined;
|
|
12
|
+
shape?: 'text' | 'block' | 'circle' | undefined;
|
|
13
|
+
/** Repeat as stacked lines, e.g. a paragraph placeholder. */
|
|
14
|
+
lines?: number | undefined;
|
|
15
|
+
class?: string | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Skeleton(props: SkeletonProps): JSX.Element {
|
|
19
|
+
const one = (index: number): JSX.Element => (
|
|
20
|
+
<span
|
|
21
|
+
class={cx(styles['skeleton'], styles[`shape-${props.shape ?? 'text'}`])}
|
|
22
|
+
style={{
|
|
23
|
+
'--skeleton-w': index > 0 && props.shape !== 'circle' ? '70%' : (props.width ?? '100%'),
|
|
24
|
+
'--skeleton-h': props.height ?? '1em',
|
|
25
|
+
}}
|
|
26
|
+
/>
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<span aria-hidden="true" class={cx(styles['group'], props.class)}>
|
|
31
|
+
{Array.from({ length: Math.max(1, props.lines ?? 1) }, (_unused, index) => one(index))}
|
|
32
|
+
</span>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
@keyframes ultimate-spin {
|
|
4
|
+
to {
|
|
5
|
+
transform: rotate(360deg);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.spinner {
|
|
10
|
+
display: inline-block;
|
|
11
|
+
border: 2px solid t.role('line');
|
|
12
|
+
border-block-start-color: t.role('accent');
|
|
13
|
+
border-radius: t.radius(full);
|
|
14
|
+
animation: ultimate-spin 700ms linear infinite;
|
|
15
|
+
// Reduced motion is handled globally in reset.scss; the ring still reads as
|
|
16
|
+
// a distinct shape when the animation is neutralised.
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.size-sm {
|
|
20
|
+
inline-size: 0.875rem;
|
|
21
|
+
block-size: 0.875rem;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.size-md {
|
|
25
|
+
inline-size: 1.25rem;
|
|
26
|
+
block-size: 1.25rem;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.size-lg {
|
|
30
|
+
inline-size: 2rem;
|
|
31
|
+
block-size: 2rem;
|
|
32
|
+
border-width: 3px;
|
|
33
|
+
}
|