@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,93 @@
|
|
|
1
|
+
// Theme control. `toggle` flips light/dark; `select` also offers "system", which
|
|
2
|
+
// clears the stored choice so the OS takes over again. All strings come from the
|
|
3
|
+
// catalog, and the DOM work happens in theme.ts, never here.
|
|
4
|
+
|
|
5
|
+
import type { JSX } from 'solid-js';
|
|
6
|
+
import { cx } from '../cx';
|
|
7
|
+
import { UI_KEYS } from '../i18n-keys';
|
|
8
|
+
import { useUi } from '../theme/context';
|
|
9
|
+
import { solid } from '../theme/solid-adapter';
|
|
10
|
+
import {
|
|
11
|
+
browserThemeEnv,
|
|
12
|
+
clearTheme,
|
|
13
|
+
resolveTheme,
|
|
14
|
+
setTheme,
|
|
15
|
+
storedTheme,
|
|
16
|
+
type Theme,
|
|
17
|
+
type ThemeEnv,
|
|
18
|
+
toggleTheme,
|
|
19
|
+
watchOsTheme,
|
|
20
|
+
} from '../theme/theme';
|
|
21
|
+
import { IconButton } from './IconButton';
|
|
22
|
+
import { Select } from './Select';
|
|
23
|
+
import styles from './ThemeToggle.module.scss';
|
|
24
|
+
|
|
25
|
+
export type ThemeChoice = Theme | 'system';
|
|
26
|
+
|
|
27
|
+
export interface ThemeToggleProps {
|
|
28
|
+
mode?: 'toggle' | 'select' | undefined;
|
|
29
|
+
/** Server-render value; the effect corrects it on the client before paint. */
|
|
30
|
+
initial?: Theme | undefined;
|
|
31
|
+
/** Injectable for tests and for non-DOM hosts. */
|
|
32
|
+
env?: ThemeEnv | undefined;
|
|
33
|
+
class?: string | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function ThemeToggle(props: ThemeToggleProps): JSX.Element {
|
|
37
|
+
const ui = useUi();
|
|
38
|
+
const rt = solid();
|
|
39
|
+
const [choice, setChoice] = rt.createSignal<ThemeChoice>(props.initial ?? 'system');
|
|
40
|
+
const [resolved, setResolved] = rt.createSignal<Theme>(props.initial ?? 'light');
|
|
41
|
+
|
|
42
|
+
const env = (): ThemeEnv => props.env ?? browserThemeEnv();
|
|
43
|
+
|
|
44
|
+
const sync = (): void => {
|
|
45
|
+
const current = env();
|
|
46
|
+
setChoice(storedTheme(current) ?? 'system');
|
|
47
|
+
setResolved(resolveTheme(current));
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
rt.createEffect(() => {
|
|
51
|
+
if (props.env === undefined && typeof window === 'undefined') return;
|
|
52
|
+
sync();
|
|
53
|
+
const stop = watchOsTheme(env());
|
|
54
|
+
rt.onCleanup(stop);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const apply = (next: ThemeChoice): void => {
|
|
58
|
+
if (next === 'system') clearTheme(env());
|
|
59
|
+
else setTheme(next, env());
|
|
60
|
+
sync();
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
if (props.mode === 'select') {
|
|
64
|
+
return (
|
|
65
|
+
<Select
|
|
66
|
+
class={cx(styles['select'], props.class)}
|
|
67
|
+
aria-label={ui.t(UI_KEYS.theme)}
|
|
68
|
+
value={choice()}
|
|
69
|
+
options={[
|
|
70
|
+
{ value: 'system', label: ui.t(UI_KEYS.themeSystem) },
|
|
71
|
+
{ value: 'light', label: ui.t(UI_KEYS.themeLight) },
|
|
72
|
+
{ value: 'dark', label: ui.t(UI_KEYS.themeDark) },
|
|
73
|
+
]}
|
|
74
|
+
onChange={(event) => apply(event.currentTarget.value as ThemeChoice)}
|
|
75
|
+
/>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<IconButton
|
|
81
|
+
class={cx(styles['toggle'], props.class)}
|
|
82
|
+
label={resolved() === 'dark' ? ui.t(UI_KEYS.themeLight) : ui.t(UI_KEYS.themeDark)}
|
|
83
|
+
aria-pressed={resolved() === 'dark'}
|
|
84
|
+
round
|
|
85
|
+
onClick={() => {
|
|
86
|
+
toggleTheme(env());
|
|
87
|
+
sync();
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<span class={styles['glyph']} data-theme-glyph={resolved()} />
|
|
91
|
+
</IconButton>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
@keyframes ultimate-toast-in {
|
|
4
|
+
from {
|
|
5
|
+
opacity: 0;
|
|
6
|
+
translate: 0 12px;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.region {
|
|
11
|
+
position: fixed;
|
|
12
|
+
z-index: t.z(toast);
|
|
13
|
+
padding: t.space(4);
|
|
14
|
+
pointer-events: none;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.placement-block-end-inline-end {
|
|
18
|
+
inset-block-end: 0;
|
|
19
|
+
inset-inline-end: 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.placement-block-start-inline-end {
|
|
23
|
+
inset-block-start: 0;
|
|
24
|
+
inset-inline-end: 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.placement-block-end-center {
|
|
28
|
+
inset-block-end: 0;
|
|
29
|
+
inset-inline: 0;
|
|
30
|
+
display: flex;
|
|
31
|
+
justify-content: center;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.list {
|
|
35
|
+
display: flex;
|
|
36
|
+
flex-direction: column;
|
|
37
|
+
gap: t.space(2);
|
|
38
|
+
inline-size: min(24rem, calc(100vw - #{t.space(8)}));
|
|
39
|
+
list-style: none;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.toast {
|
|
43
|
+
@include t.surface('surface-raised', 'lg');
|
|
44
|
+
display: flex;
|
|
45
|
+
align-items: flex-start;
|
|
46
|
+
gap: t.space(3);
|
|
47
|
+
padding: t.space(4);
|
|
48
|
+
border-inline-start: 3px solid var(--toast-accent);
|
|
49
|
+
color: t.role('fg');
|
|
50
|
+
animation: ultimate-toast-in t.duration(base) t.easing(out);
|
|
51
|
+
pointer-events: auto;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.content {
|
|
55
|
+
flex: 1;
|
|
56
|
+
min-inline-size: 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.title {
|
|
60
|
+
color: t.role('fg-strong');
|
|
61
|
+
font-weight: t.weight(semibold);
|
|
62
|
+
font-size: t.text(sm);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.body {
|
|
66
|
+
color: t.role('fg-muted');
|
|
67
|
+
font-size: t.text(sm);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.tone-neutral {
|
|
71
|
+
--toast-accent: #{t.role('line')};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.tone-accent {
|
|
75
|
+
--toast-accent: #{t.role('accent')};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.tone-success {
|
|
79
|
+
--toast-accent: #{t.role('success')};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.tone-warning {
|
|
83
|
+
--toast-accent: #{t.role('warning')};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.tone-danger {
|
|
87
|
+
--toast-accent: #{t.role('danger')};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.tone-info {
|
|
91
|
+
--toast-accent: #{t.role('info')};
|
|
92
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Transient notification. ToastRegion is the single live region for the app;
|
|
2
|
+
// individual Toasts are its children, so announcements are not duplicated and
|
|
3
|
+
// the region exists before the first message (screen readers require that).
|
|
4
|
+
|
|
5
|
+
import type { JSX } from 'solid-js';
|
|
6
|
+
import { cx } from '../cx';
|
|
7
|
+
import { UI_KEYS } from '../i18n-keys';
|
|
8
|
+
import { useUi } from '../theme/context';
|
|
9
|
+
import { IconButton } from './IconButton';
|
|
10
|
+
import styles from './Toast.module.scss';
|
|
11
|
+
import type { Tone } from './variants';
|
|
12
|
+
|
|
13
|
+
export interface ToastRegionProps {
|
|
14
|
+
children: JSX.Element;
|
|
15
|
+
/** Already-translated landmark name, e.g. "Notifications". */
|
|
16
|
+
label: string;
|
|
17
|
+
placement?: 'block-end-inline-end' | 'block-start-inline-end' | 'block-end-center' | undefined;
|
|
18
|
+
class?: string | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ToastRegion(props: ToastRegionProps): JSX.Element {
|
|
22
|
+
return (
|
|
23
|
+
<section
|
|
24
|
+
class={cx(
|
|
25
|
+
styles['region'],
|
|
26
|
+
styles[`placement-${props.placement ?? 'block-end-inline-end'}`],
|
|
27
|
+
props.class,
|
|
28
|
+
)}
|
|
29
|
+
aria-label={props.label}
|
|
30
|
+
>
|
|
31
|
+
<ol class={styles['list']}>{props.children}</ol>
|
|
32
|
+
</section>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ToastProps {
|
|
37
|
+
/** Already-translated message. */
|
|
38
|
+
children: JSX.Element;
|
|
39
|
+
title?: string | undefined;
|
|
40
|
+
tone?: Tone | undefined;
|
|
41
|
+
action?: JSX.Element | undefined;
|
|
42
|
+
onDismiss?: (() => void) | undefined;
|
|
43
|
+
dismissLabel?: string | undefined;
|
|
44
|
+
class?: string | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function Toast(props: ToastProps): JSX.Element {
|
|
48
|
+
const ui = useUi();
|
|
49
|
+
const tone = (): Tone => props.tone ?? 'neutral';
|
|
50
|
+
const assertive = (): boolean => tone() === 'danger';
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<li
|
|
54
|
+
class={cx(styles['toast'], styles[`tone-${tone()}`], props.class)}
|
|
55
|
+
role={assertive() ? 'alert' : 'status'}
|
|
56
|
+
aria-live={assertive() ? 'assertive' : 'polite'}
|
|
57
|
+
>
|
|
58
|
+
<div class={styles['content']}>
|
|
59
|
+
{props.title === undefined ? null : <p class={styles['title']}>{props.title}</p>}
|
|
60
|
+
<div class={styles['body']}>{props.children}</div>
|
|
61
|
+
</div>
|
|
62
|
+
{props.action}
|
|
63
|
+
{props.onDismiss === undefined ? null : (
|
|
64
|
+
<IconButton
|
|
65
|
+
label={props.dismissLabel ?? ui.t(UI_KEYS.dismiss)}
|
|
66
|
+
size="sm"
|
|
67
|
+
onClick={() => props.onDismiss?.()}
|
|
68
|
+
>
|
|
69
|
+
<span aria-hidden="true">×</span>
|
|
70
|
+
</IconButton>
|
|
71
|
+
)}
|
|
72
|
+
</li>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.wrap {
|
|
4
|
+
position: relative;
|
|
5
|
+
display: inline-flex;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.bubble {
|
|
9
|
+
@include t.transition(opacity, translate);
|
|
10
|
+
position: absolute;
|
|
11
|
+
z-index: t.z(tooltip);
|
|
12
|
+
inset-inline-start: 50%;
|
|
13
|
+
translate: -50% 4px;
|
|
14
|
+
max-inline-size: 18rem;
|
|
15
|
+
padding-block: t.space(1);
|
|
16
|
+
padding-inline: t.space(2);
|
|
17
|
+
border-radius: t.radius(sm);
|
|
18
|
+
background: t.role('fg-strong');
|
|
19
|
+
color: t.role('bg');
|
|
20
|
+
font-size: t.text(xs);
|
|
21
|
+
line-height: t.leading(snug);
|
|
22
|
+
text-align: center;
|
|
23
|
+
white-space: normal;
|
|
24
|
+
opacity: 0;
|
|
25
|
+
pointer-events: none;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.placement-block-start {
|
|
29
|
+
inset-block-end: calc(100% + #{t.space(1)});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.placement-block-end {
|
|
33
|
+
inset-block-start: calc(100% + #{t.space(1)});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Hover and keyboard focus both reveal it — a hover-only tooltip is inaccessible.
|
|
37
|
+
.wrap:hover .bubble,
|
|
38
|
+
.wrap:focus-within .bubble {
|
|
39
|
+
translate: -50% 0;
|
|
40
|
+
opacity: 1;
|
|
41
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Supplementary hint, shown on hover AND on keyboard focus. CSS-only, so it
|
|
2
|
+
// costs no JS; the content is wired with `aria-describedby`, never `title`,
|
|
3
|
+
// because `title` is unreachable by keyboard and untranslatable by the platform.
|
|
4
|
+
|
|
5
|
+
import type { JSX } from 'solid-js';
|
|
6
|
+
import { useId } from '../a11y';
|
|
7
|
+
import { cx } from '../cx';
|
|
8
|
+
import styles from './Tooltip.module.scss';
|
|
9
|
+
|
|
10
|
+
export interface TooltipProps {
|
|
11
|
+
/** Already-translated hint text. Never the element's only accessible name. */
|
|
12
|
+
content: string;
|
|
13
|
+
children: (control: { 'aria-describedby': string }) => JSX.Element;
|
|
14
|
+
placement?: 'block-start' | 'block-end' | undefined;
|
|
15
|
+
class?: string | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Tooltip(props: TooltipProps): JSX.Element {
|
|
19
|
+
const id = useId('tooltip');
|
|
20
|
+
return (
|
|
21
|
+
<span class={cx(styles['wrap'], props.class)}>
|
|
22
|
+
{props.children({ 'aria-describedby': id })}
|
|
23
|
+
<span
|
|
24
|
+
id={id}
|
|
25
|
+
role="tooltip"
|
|
26
|
+
class={cx(styles['bubble'], styles[`placement-${props.placement ?? 'block-start'}`])}
|
|
27
|
+
>
|
|
28
|
+
{props.content}
|
|
29
|
+
</span>
|
|
30
|
+
</span>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Pure formatting core behind <DateTime>. Produces both halves the component
|
|
2
|
+
// needs: the machine-readable ISO instant for `<time datetime>` and the human
|
|
3
|
+
// string formatted in the injected zone + locale.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type DateTimeStyle,
|
|
7
|
+
type FormatDateTimeOptions,
|
|
8
|
+
formatDateTime,
|
|
9
|
+
type Instant,
|
|
10
|
+
instant,
|
|
11
|
+
type TimeZone,
|
|
12
|
+
} from '@ultimat3/time';
|
|
13
|
+
import { invalidValueError } from '../errors';
|
|
14
|
+
|
|
15
|
+
/** What a component may be handed for a point in time. */
|
|
16
|
+
export type TimeInput = Date | string | number | Instant;
|
|
17
|
+
|
|
18
|
+
export type DateStyle = DateTimeStyle;
|
|
19
|
+
|
|
20
|
+
export type DateTimeFormatter = (at: Instant, options: FormatDateTimeOptions) => string;
|
|
21
|
+
|
|
22
|
+
export interface DateTimeViewOptions {
|
|
23
|
+
value: TimeInput;
|
|
24
|
+
/** Required: there is no ambient locale in this package. */
|
|
25
|
+
locale: string;
|
|
26
|
+
/** Required: a date is never formatted without an explicit IANA zone. */
|
|
27
|
+
timeZone: TimeZone;
|
|
28
|
+
dateStyle?: DateStyle | undefined;
|
|
29
|
+
timeStyle?: DateStyle | undefined;
|
|
30
|
+
/** Override for tests or a custom calendar. */
|
|
31
|
+
format?: DateTimeFormatter | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DateTimeView {
|
|
35
|
+
/** UTC ISO-8601 — the value that goes in the `datetime` attribute. */
|
|
36
|
+
readonly dateTime: string;
|
|
37
|
+
/** Localised, zoned text — the value a human reads. */
|
|
38
|
+
readonly text: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function toDate(value: TimeInput): Date {
|
|
42
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
43
|
+
if (Number.isNaN(date.getTime())) {
|
|
44
|
+
throw invalidValueError('DateTime', value, 'a valid Date, ISO string, or epoch millis');
|
|
45
|
+
}
|
|
46
|
+
return date;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function toInstant(value: TimeInput): Instant {
|
|
50
|
+
return instant(toDate(value));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Always UTC, so the attribute is comparable across zones and machines. */
|
|
54
|
+
export function toIsoInstant(value: TimeInput): string {
|
|
55
|
+
return toDate(value).toISOString();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function dateTimeView(view: DateTimeViewOptions): DateTimeView {
|
|
59
|
+
const at = toInstant(view.value);
|
|
60
|
+
const format = view.format ?? formatDateTime;
|
|
61
|
+
const options: FormatDateTimeOptions = {
|
|
62
|
+
locale: view.locale,
|
|
63
|
+
zone: view.timeZone,
|
|
64
|
+
...(view.dateStyle === undefined ? {} : { dateStyle: view.dateStyle }),
|
|
65
|
+
...(view.timeStyle === undefined ? {} : { timeStyle: view.timeStyle }),
|
|
66
|
+
};
|
|
67
|
+
return { dateTime: at.toISOString(), text: format(at, options) };
|
|
68
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Pure srcset core behind <Image>. Split out so the descriptor rules — one kind
|
|
2
|
+
// per set, ascending, no duplicates — are testable without a renderer, and so a
|
|
3
|
+
// malformed variant list fails where it is written instead of shipping a srcset
|
|
4
|
+
// the browser silently ignores.
|
|
5
|
+
|
|
6
|
+
import { invalidValueError } from '../errors';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One rendition of an image. Exactly one of `width` (emits a `w` descriptor) or
|
|
10
|
+
* `density` (an `x` descriptor): HTML forbids mixing the two in one srcset.
|
|
11
|
+
*/
|
|
12
|
+
export interface ImageVariant {
|
|
13
|
+
src: string;
|
|
14
|
+
/** Intrinsic width in CSS pixels. Pairs with `sizes`. */
|
|
15
|
+
width?: number | undefined;
|
|
16
|
+
/** Device pixel ratio this rendition targets. Use when the box is fixed. */
|
|
17
|
+
density?: number | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** What `priority` resolves to on the element — never decided at the call site. */
|
|
21
|
+
export interface ImageLoadingHints {
|
|
22
|
+
loading: 'eager' | 'lazy';
|
|
23
|
+
fetchpriority: 'high' | 'auto';
|
|
24
|
+
decoding: 'async';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Intrinsic dimensions to inline. Both, or the browser has no ratio to reserve. */
|
|
28
|
+
export interface ImageBox {
|
|
29
|
+
width: number;
|
|
30
|
+
height: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Candidate {
|
|
34
|
+
src: string;
|
|
35
|
+
kind: 'w' | 'x';
|
|
36
|
+
value: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The `srcset` attribute for a variant list, ordered ascending so the markup is
|
|
41
|
+
* byte-stable whatever order the caller built the list in. `undefined` for an
|
|
42
|
+
* empty list — the component omits the attribute rather than emitting an empty one.
|
|
43
|
+
*/
|
|
44
|
+
export function srcsetFor(variants: readonly ImageVariant[] | undefined): string | undefined {
|
|
45
|
+
if (variants === undefined || variants.length === 0) return undefined;
|
|
46
|
+
|
|
47
|
+
const candidates = variants.map(toCandidate);
|
|
48
|
+
if (new Set(candidates.map((candidate) => candidate.kind)).size > 1) {
|
|
49
|
+
throw invalidValueError(
|
|
50
|
+
'Image',
|
|
51
|
+
variants,
|
|
52
|
+
'a variant list of one descriptor kind — every variant with width, or every variant with density',
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const seen = new Set<string>();
|
|
57
|
+
for (const candidate of candidates) {
|
|
58
|
+
const descriptor = descriptorOf(candidate);
|
|
59
|
+
if (seen.has(descriptor)) {
|
|
60
|
+
throw invalidValueError(
|
|
61
|
+
'Image',
|
|
62
|
+
variants,
|
|
63
|
+
`a variant list with distinct descriptors — ${descriptor} appears twice`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
seen.add(descriptor);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return [...candidates]
|
|
70
|
+
.sort((a, b) => a.value - b.value)
|
|
71
|
+
.map((candidate) => `${candidate.src} ${descriptorOf(candidate)}`)
|
|
72
|
+
.join(', ');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `priority` is the only knob: eager + high priority for the LCP image, lazy +
|
|
77
|
+
* auto for every other one. Decoding is always async — a sync decode blocks paint.
|
|
78
|
+
*/
|
|
79
|
+
export function loadingHints(priority: boolean | undefined): ImageLoadingHints {
|
|
80
|
+
return priority === true
|
|
81
|
+
? { loading: 'eager', fetchpriority: 'high', decoding: 'async' }
|
|
82
|
+
: { loading: 'lazy', fetchpriority: 'auto', decoding: 'async' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Both dimensions or neither: one alone gives the browser no aspect ratio to
|
|
87
|
+
* reserve, which is the layout shift <Image> exists to prevent.
|
|
88
|
+
*/
|
|
89
|
+
export function boxFor(
|
|
90
|
+
width: number | undefined,
|
|
91
|
+
height: number | undefined,
|
|
92
|
+
): ImageBox | undefined {
|
|
93
|
+
if (width === undefined && height === undefined) return undefined;
|
|
94
|
+
if (width === undefined || height === undefined) {
|
|
95
|
+
throw invalidValueError(
|
|
96
|
+
'Image',
|
|
97
|
+
{ width, height },
|
|
98
|
+
'both width and height, or neither — one alone reserves no aspect ratio',
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
assertPixels(width);
|
|
102
|
+
assertPixels(height);
|
|
103
|
+
return { width, height };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A `src` with real content once whitespace is trimmed. Shared by the primary `src` prop and
|
|
108
|
+
* every variant `src`: an empty or blank one emits a broken `<img>` or a srcset entry the
|
|
109
|
+
* browser silently drops, so both paths reject it the same way instead of one staying permissive.
|
|
110
|
+
*/
|
|
111
|
+
export function assertNonEmptySrc(kind: string, value: unknown, src: string): string {
|
|
112
|
+
const trimmed = src.trim();
|
|
113
|
+
if (trimmed === '') {
|
|
114
|
+
throw invalidValueError(kind, value, 'a non-empty src');
|
|
115
|
+
}
|
|
116
|
+
return trimmed;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function toCandidate(variant: ImageVariant): Candidate {
|
|
120
|
+
const src = assertNonEmptySrc('Image', variant, variant.src);
|
|
121
|
+
|
|
122
|
+
const width = variant.width;
|
|
123
|
+
const density = variant.density;
|
|
124
|
+
if ((width === undefined) === (density === undefined)) {
|
|
125
|
+
throw invalidValueError(
|
|
126
|
+
'Image',
|
|
127
|
+
variant,
|
|
128
|
+
'a variant with exactly one of width (a w descriptor) or density (an x descriptor)',
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (width !== undefined) {
|
|
133
|
+
assertPixels(width);
|
|
134
|
+
return { src, kind: 'w', value: width };
|
|
135
|
+
}
|
|
136
|
+
if (density === undefined || !Number.isFinite(density) || density <= 0) {
|
|
137
|
+
throw invalidValueError('Image', variant, 'a positive density, such as 1, 1.5 or 2');
|
|
138
|
+
}
|
|
139
|
+
return { src, kind: 'x', value: density };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function descriptorOf(candidate: Candidate): string {
|
|
143
|
+
return `${candidate.value}${candidate.kind}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function assertPixels(value: number): void {
|
|
147
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
148
|
+
throw invalidValueError('Image', value, 'a positive whole number of CSS pixels');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Pure formatting core behind <Money>. Split out so the rule "format through the
|
|
2
|
+
// injected locale, never an ambient default" is testable without a renderer.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type FormatMoneyOptions,
|
|
6
|
+
formatMoney,
|
|
7
|
+
type Money,
|
|
8
|
+
money as makeMoney,
|
|
9
|
+
} from '@ultimat3/money';
|
|
10
|
+
import { invalidValueError } from '../errors';
|
|
11
|
+
|
|
12
|
+
/** A bare number is minor units in the context currency. */
|
|
13
|
+
export type MoneyInput = Money | number;
|
|
14
|
+
|
|
15
|
+
export type MoneyFormatter = (
|
|
16
|
+
amount: Money,
|
|
17
|
+
locale: string,
|
|
18
|
+
options?: FormatMoneyOptions,
|
|
19
|
+
) => string;
|
|
20
|
+
|
|
21
|
+
export interface MoneyViewOptions {
|
|
22
|
+
value: MoneyInput;
|
|
23
|
+
/** Required: there is no ambient locale in this package. */
|
|
24
|
+
locale: string;
|
|
25
|
+
/** Used only when `value` is a bare minor-unit number. */
|
|
26
|
+
currency: string;
|
|
27
|
+
/** Passed through to @ultimat3/money: display, accounting, grouping. */
|
|
28
|
+
options?: FormatMoneyOptions | undefined;
|
|
29
|
+
/** Override for tests, custom rounding, or a non-Intl renderer. */
|
|
30
|
+
format?: MoneyFormatter | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function toMoney(value: MoneyInput, currency: string): Money {
|
|
34
|
+
if (typeof value === 'number') {
|
|
35
|
+
if (!Number.isInteger(value)) {
|
|
36
|
+
throw invalidValueError('Money', value, 'an integer number of minor units');
|
|
37
|
+
}
|
|
38
|
+
// Delegated so the currency code is validated by the one authority on it.
|
|
39
|
+
return makeMoney(value, currency);
|
|
40
|
+
}
|
|
41
|
+
if (!Number.isInteger(value.minor)) {
|
|
42
|
+
throw invalidValueError('Money', value, 'an integer number of minor units');
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function moneyText(view: MoneyViewOptions): string {
|
|
48
|
+
const format = view.format ?? formatMoney;
|
|
49
|
+
return format(toMoney(view.value, view.currency), view.locale, view.options);
|
|
50
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Pure core behind <RelativeTime>. Uses Intl.RelativeTimeFormat against the
|
|
2
|
+
// injected locale; the caller supplies `now` so output is deterministic in tests
|
|
3
|
+
// and identical between the server render and the client hydrate.
|
|
4
|
+
|
|
5
|
+
import { type TimeInput, toDate } from './date-time-view';
|
|
6
|
+
|
|
7
|
+
export interface RelativeTimeOptions {
|
|
8
|
+
value: TimeInput;
|
|
9
|
+
locale: string;
|
|
10
|
+
/** Defaults to the current instant. Pass it explicitly for SSR parity. */
|
|
11
|
+
now?: TimeInput | undefined;
|
|
12
|
+
numeric?: 'always' | 'auto' | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Threshold {
|
|
16
|
+
readonly unit: Intl.RelativeTimeFormatUnit;
|
|
17
|
+
readonly ms: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const THRESHOLDS: readonly Threshold[] = [
|
|
21
|
+
{ unit: 'year', ms: 365 * 24 * 60 * 60 * 1000 },
|
|
22
|
+
{ unit: 'month', ms: 30 * 24 * 60 * 60 * 1000 },
|
|
23
|
+
{ unit: 'week', ms: 7 * 24 * 60 * 60 * 1000 },
|
|
24
|
+
{ unit: 'day', ms: 24 * 60 * 60 * 1000 },
|
|
25
|
+
{ unit: 'hour', ms: 60 * 60 * 1000 },
|
|
26
|
+
{ unit: 'minute', ms: 60 * 1000 },
|
|
27
|
+
{ unit: 'second', ms: 1000 },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export function relativeTimeText(options: RelativeTimeOptions): string {
|
|
31
|
+
const target = toDate(options.value).getTime();
|
|
32
|
+
const base = options.now === undefined ? Date.now() : toDate(options.now).getTime();
|
|
33
|
+
const delta = target - base;
|
|
34
|
+
const magnitude = Math.abs(delta);
|
|
35
|
+
|
|
36
|
+
const threshold =
|
|
37
|
+
THRESHOLDS.find((candidate) => magnitude >= candidate.ms) ??
|
|
38
|
+
(THRESHOLDS[THRESHOLDS.length - 1] as Threshold);
|
|
39
|
+
const value = Math.round(delta / threshold.ms);
|
|
40
|
+
|
|
41
|
+
const formatter = new Intl.RelativeTimeFormat(options.locale, {
|
|
42
|
+
numeric: options.numeric ?? 'auto',
|
|
43
|
+
});
|
|
44
|
+
return formatter.format(magnitude < 1000 ? 0 : value, threshold.unit);
|
|
45
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Pure sort-state reducer shared by Table and DataTable. Kept out of the .tsx so
|
|
2
|
+
// the ordering rules are testable without a renderer.
|
|
3
|
+
|
|
4
|
+
export type SortDirection = 'asc' | 'desc';
|
|
5
|
+
|
|
6
|
+
export interface SortState {
|
|
7
|
+
readonly key: string;
|
|
8
|
+
readonly direction: SortDirection;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Click cycle on a column header: unsorted -> asc -> desc -> unsorted.
|
|
13
|
+
* Clicking a different column always starts that column at `asc`.
|
|
14
|
+
*/
|
|
15
|
+
export function nextSortState(current: SortState | undefined, key: string): SortState | undefined {
|
|
16
|
+
if (current === undefined || current.key !== key) return { key, direction: 'asc' };
|
|
17
|
+
if (current.direction === 'asc') return { key, direction: 'desc' };
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The value for `aria-sort` on a column header. */
|
|
22
|
+
export function ariaSortFor(
|
|
23
|
+
current: SortState | undefined,
|
|
24
|
+
key: string,
|
|
25
|
+
): 'ascending' | 'descending' | 'none' {
|
|
26
|
+
if (current === undefined || current.key !== key) return 'none';
|
|
27
|
+
return current.direction === 'asc' ? 'ascending' : 'descending';
|
|
28
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The shared vocabulary every component's props draw from. One size scale, one
|
|
2
|
+
// tone scale — a component that needs a new rung adds it here, not locally.
|
|
3
|
+
|
|
4
|
+
export const SIZES = ['sm', 'md', 'lg'] as const;
|
|
5
|
+
export type Size = (typeof SIZES)[number];
|
|
6
|
+
|
|
7
|
+
/** Maps 1:1 onto the status colour roles in `_colors.scss`. */
|
|
8
|
+
export const TONES = ['neutral', 'accent', 'success', 'warning', 'danger', 'info'] as const;
|
|
9
|
+
export type Tone = (typeof TONES)[number];
|
|
10
|
+
|
|
11
|
+
export const BUTTON_VARIANTS = ['primary', 'secondary', 'ghost', 'link'] as const;
|
|
12
|
+
export type ButtonVariant = (typeof BUTTON_VARIANTS)[number];
|
|
13
|
+
|
|
14
|
+
export type Align = 'start' | 'center' | 'end' | 'stretch' | 'between';
|
|
15
|
+
|
|
16
|
+
/** Space token step, as used by the layout primitives. */
|
|
17
|
+
export type SpaceStep = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12 | 16;
|