@ultimat3/ui 22.15.0 → 23.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 +43 -12
- package/CLAUDE.md +13 -49
- package/README.md +59 -4
- package/package.json +6 -5
- 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 +1 -1
- package/src/components/DataTable.module.scss +2 -0
- package/src/components/DataTable.tsx +90 -29
- package/src/components/Dialog.module.scss +2 -2
- package/src/components/Dropzone.module.scss +3 -3
- package/src/components/FileInput.module.scss +1 -1
- 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/Pagination.tsx +89 -37
- 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 +5 -5
- package/src/components/ThemeToggle.module.scss +1 -1
- package/src/components/Toast.module.scss +2 -2
- package/src/components/Tooltip.module.scss +1 -1
- package/src/components/bar-chart-view.ts +29 -3
- package/src/components/button-classes.ts +25 -0
- package/src/components/link-classes.ts +38 -0
- package/src/components/link-target.ts +13 -0
- package/src/index.ts +13 -3
- package/src/sass-probe.ts +55 -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
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// A bar chart of one series — clicks per day, signups per week —
|
|
1
|
+
// A bar chart of one series — clicks per day, signups per week — or two stacked (done, and failed
|
|
2
|
+
// on top), as static SVG. No charting
|
|
2
3
|
// library: the framework's own docs use a sparkline pulling one in as the cautionary example, and
|
|
3
4
|
// a route's JS budget counts raw minified bytes. `<rect>` bars cost nothing to hydrate, so the
|
|
4
5
|
// server-rendered shell IS the chart. Geometry lives in `bar-chart-view.ts`.
|
|
@@ -7,7 +8,7 @@ import type { JSX } from 'solid-js';
|
|
|
7
8
|
import { cx } from '../cx';
|
|
8
9
|
import styles from './BarChart.module.scss';
|
|
9
10
|
import type { ChartPoint } from './bar-chart-view';
|
|
10
|
-
import { BAR_CHART, barRects, GRID_STEPS, gridY, maxOf } from './bar-chart-view';
|
|
11
|
+
import { BAR_CHART, barRects, GRID_STEPS, gridY, maxOf, secondaryRects } from './bar-chart-view';
|
|
11
12
|
|
|
12
13
|
export interface BarChartProps {
|
|
13
14
|
/** The accessible name for the whole chart, already translated. */
|
|
@@ -16,11 +17,19 @@ export interface BarChartProps {
|
|
|
16
17
|
points: readonly ChartPoint[];
|
|
17
18
|
/** Draw the last bar at full strength — the eye lands where the live number is. Default on. */
|
|
18
19
|
highlightLast?: boolean | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Names of the two series, already translated, read by each rect's `<title>`. Give them when a
|
|
22
|
+
* point carries `secondary`: a stacked bar with no name for its top is two numbers and no key.
|
|
23
|
+
*/
|
|
24
|
+
seriesLabels?: { readonly primary: string; readonly secondary: string } | undefined;
|
|
19
25
|
class?: string | undefined;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
export function BarChart(props: BarChartProps): JSX.Element {
|
|
23
29
|
const rects = (): readonly ReturnType<typeof barRects>[number][] => barRects(props.points);
|
|
30
|
+
const tops = (): ReturnType<typeof secondaryRects> => secondaryRects(props.points);
|
|
31
|
+
const named = (label: string | undefined, value: number): string =>
|
|
32
|
+
label === undefined ? String(value) : `${label} ${value}`;
|
|
24
33
|
const lastIndex = (): number => props.points.length - 1;
|
|
25
34
|
const first = (): ChartPoint | undefined => props.points[0];
|
|
26
35
|
const last = (): ChartPoint | undefined => props.points.at(-1);
|
|
@@ -44,20 +53,37 @@ export function BarChart(props: BarChartProps): JSX.Element {
|
|
|
44
53
|
{props.points.map((point, index) => {
|
|
45
54
|
const rect = rects()[index];
|
|
46
55
|
if (rect === undefined) return null;
|
|
56
|
+
const top = tops()[index] ?? null;
|
|
47
57
|
return (
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
{point.key}: {point.value}
|
|
59
|
-
</
|
|
60
|
-
|
|
58
|
+
<g>
|
|
59
|
+
<rect
|
|
60
|
+
class={cx(styles['bar'], highlight() && index === lastIndex() && styles['last'])}
|
|
61
|
+
x={rect.x}
|
|
62
|
+
y={rect.y}
|
|
63
|
+
width={rect.width}
|
|
64
|
+
height={rect.height}
|
|
65
|
+
rx={2}
|
|
66
|
+
data-bar="true"
|
|
67
|
+
>
|
|
68
|
+
<title>{`${point.key}: ${named(props.seriesLabels?.primary, point.value)}`}</title>
|
|
69
|
+
</rect>
|
|
70
|
+
{top === null ? null : (
|
|
71
|
+
<rect
|
|
72
|
+
class={cx(
|
|
73
|
+
styles['secondary'],
|
|
74
|
+
highlight() && index === lastIndex() && styles['last'],
|
|
75
|
+
)}
|
|
76
|
+
x={top.x}
|
|
77
|
+
y={top.y}
|
|
78
|
+
width={top.width}
|
|
79
|
+
height={top.height}
|
|
80
|
+
rx={2}
|
|
81
|
+
data-series="secondary"
|
|
82
|
+
>
|
|
83
|
+
<title>{`${point.key}: ${named(props.seriesLabels?.secondary, point.secondary ?? 0)}`}</title>
|
|
84
|
+
</rect>
|
|
85
|
+
)}
|
|
86
|
+
</g>
|
|
61
87
|
);
|
|
62
88
|
})}
|
|
63
89
|
<text class={styles['axis']} x={BAR_CHART.width} y={BAR_CHART.top - 3} text-anchor="end">
|
|
@@ -14,6 +14,7 @@ import type { JSX } from 'solid-js';
|
|
|
14
14
|
import { ariaBool } from '../a11y';
|
|
15
15
|
import { cx } from '../cx';
|
|
16
16
|
import styles from './Button.module.scss';
|
|
17
|
+
import { buttonClassKeys } from './button-classes';
|
|
17
18
|
import { Spinner } from './Spinner';
|
|
18
19
|
import type { ButtonVariant, Size, Tone } from './variants';
|
|
19
20
|
|
|
@@ -66,14 +67,8 @@ export function Button(props: ButtonProps): JSX.Element {
|
|
|
66
67
|
<button
|
|
67
68
|
id={props.id}
|
|
68
69
|
type={props.type ?? 'button'}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
styles[`variant-${props.variant ?? 'primary'}`],
|
|
72
|
-
styles[`tone-${props.tone ?? 'accent'}`],
|
|
73
|
-
styles[`size-${props.size ?? 'md'}`],
|
|
74
|
-
props.fullWidth === true && styles['full'],
|
|
75
|
-
props.class,
|
|
76
|
-
)}
|
|
70
|
+
// The keys are `button-classes.ts`'s: `<Link appearance="button">` asks the same function.
|
|
71
|
+
class={cx(...buttonClassKeys(props).map((key) => styles[key]), props.class)}
|
|
77
72
|
// Only the caller's explicit `disabled` reaches the attribute. `loading` is a state the user
|
|
78
73
|
// is meant to read and wait out, not a control taken away from under them.
|
|
79
74
|
disabled={props.disabled === true ? true : undefined}
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
inset-block-start: 0.1rem;
|
|
35
35
|
inline-size: 0.3rem;
|
|
36
36
|
block-size: 0.55rem;
|
|
37
|
-
border-inline-end:
|
|
38
|
-
border-block-end:
|
|
37
|
+
border-inline-end: t.stroke(thick) solid t.role('accent-fg');
|
|
38
|
+
border-block-end: t.stroke(thick) solid t.role('accent-fg');
|
|
39
39
|
transform: rotate(40deg) scale(0);
|
|
40
40
|
transform-origin: center;
|
|
41
41
|
content: '';
|
|
@@ -66,8 +66,8 @@
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
.input:focus-visible + .box {
|
|
69
|
-
outline:
|
|
70
|
-
outline-offset:
|
|
69
|
+
outline: t.stroke(thick) solid t.role('accent');
|
|
70
|
+
outline-offset: t.stroke(thick);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
.input[aria-invalid='true'] + .box {
|
|
@@ -12,6 +12,11 @@ export interface CheckboxProps {
|
|
|
12
12
|
id?: string | undefined;
|
|
13
13
|
name?: string | undefined;
|
|
14
14
|
value?: string | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* The id of the `<form>` this box submits with, when it cannot sit inside it — a row checkbox in
|
|
17
|
+
* a table whose page already holds another form. The platform's own association, no script.
|
|
18
|
+
*/
|
|
19
|
+
form?: string | undefined;
|
|
15
20
|
checked?: boolean | undefined;
|
|
16
21
|
/** Tri-state for "some children selected". The ONLY thing mirrored to `aria-checked`. */
|
|
17
22
|
indeterminate?: boolean | undefined;
|
|
@@ -33,6 +38,7 @@ export function Checkbox(props: CheckboxProps): JSX.Element {
|
|
|
33
38
|
id={props.id}
|
|
34
39
|
name={props.name}
|
|
35
40
|
value={props.value}
|
|
41
|
+
form={props.form}
|
|
36
42
|
checked={props.checked === true}
|
|
37
43
|
disabled={props.disabled === true}
|
|
38
44
|
required={props.required === true}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
z-index: t.z(dialog);
|
|
22
22
|
|
|
23
23
|
// Transform-only translate keeps the 50% inline offset from fighting `margin-inline`.
|
|
24
|
-
transform: translateX(-50%) translateY(-
|
|
24
|
+
transform: translateX(-50%) translateY(calc(-1 * #{t.space(1)})) scale(0.98);
|
|
25
25
|
opacity: 0;
|
|
26
26
|
visibility: hidden;
|
|
27
27
|
transition:
|
|
@@ -33,13 +33,12 @@ export interface Column<Row> {
|
|
|
33
33
|
width?: string | undefined;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
interface DataTableBaseProps<Row> {
|
|
37
37
|
caption: string;
|
|
38
38
|
columns: readonly Column<Row>[];
|
|
39
39
|
rows: readonly Row[];
|
|
40
40
|
rowKey: (row: Row) => string;
|
|
41
41
|
sort?: SortState | undefined;
|
|
42
|
-
onSortChange?: (sort: SortState | undefined) => void;
|
|
43
42
|
loading?: boolean | undefined;
|
|
44
43
|
/** An UltimateError (or anything shaped like one) from the query. */
|
|
45
44
|
error?: unknown | undefined;
|
|
@@ -49,7 +48,6 @@ export interface DataTableProps<Row> {
|
|
|
49
48
|
/** Opaque cursors from the query result; absent means no further page. */
|
|
50
49
|
nextCursor?: string | undefined;
|
|
51
50
|
prevCursor?: string | undefined;
|
|
52
|
-
onCursor?: ((cursor: string, direction: 'next' | 'prev') => void) | undefined;
|
|
53
51
|
stickyHeader?: boolean | undefined;
|
|
54
52
|
density?: 'comfortable' | 'compact' | undefined;
|
|
55
53
|
/** Placeholder row count while loading. Match the usual page size. */
|
|
@@ -57,6 +55,31 @@ export interface DataTableProps<Row> {
|
|
|
57
55
|
class?: string | undefined;
|
|
58
56
|
}
|
|
59
57
|
|
|
58
|
+
/** The pager and the sort headers call back: the table sits in an island. */
|
|
59
|
+
export interface DataTableCallbackProps<Row> extends DataTableBaseProps<Row> {
|
|
60
|
+
/** Callback paging. Never beside `hrefFor` — the type refuses the pair. */
|
|
61
|
+
onCursor?: ((cursor: string, direction: 'next' | 'prev') => void) | undefined;
|
|
62
|
+
/** Callback sorting. Never beside `sortHrefFor` — the type refuses the pair. */
|
|
63
|
+
onSortChange?: ((sort: SortState | undefined) => void) | undefined;
|
|
64
|
+
hrefFor?: undefined;
|
|
65
|
+
sortHrefFor?: undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The pager and the sort headers are anchors: a server-rendered table needs no script. */
|
|
69
|
+
export interface DataTableLinkProps<Row> extends DataTableBaseProps<Row> {
|
|
70
|
+
/** Link paging: the URL of the page a cursor names, handed to `<Pagination hrefFor>`. */
|
|
71
|
+
hrefFor: (cursor: string, direction: 'next' | 'prev') => string;
|
|
72
|
+
/**
|
|
73
|
+
* Link sorting: the URL of the list under the NEXT sort state of a header's cycle (`undefined`
|
|
74
|
+
* is "unsorted"). Absent, a sortable header is plain text — never a button with no handler.
|
|
75
|
+
*/
|
|
76
|
+
sortHrefFor?: ((sort: SortState | undefined) => string) | undefined;
|
|
77
|
+
onCursor?: undefined;
|
|
78
|
+
onSortChange?: undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type DataTableProps<Row> = DataTableCallbackProps<Row> | DataTableLinkProps<Row>;
|
|
82
|
+
|
|
60
83
|
export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
|
|
61
84
|
const ui = useUi();
|
|
62
85
|
|
|
@@ -131,6 +154,68 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
|
|
|
131
154
|
);
|
|
132
155
|
}
|
|
133
156
|
|
|
157
|
+
/** One mode per use, exactly as `Pagination` declares it — the table only hands it through. */
|
|
158
|
+
const pager = (): JSX.Element =>
|
|
159
|
+
props.hrefFor === undefined ? (
|
|
160
|
+
<Pagination
|
|
161
|
+
nextCursor={props.nextCursor}
|
|
162
|
+
prevCursor={props.prevCursor}
|
|
163
|
+
onCursor={props.onCursor}
|
|
164
|
+
/>
|
|
165
|
+
) : (
|
|
166
|
+
<Pagination
|
|
167
|
+
nextCursor={props.nextCursor}
|
|
168
|
+
prevCursor={props.prevCursor}
|
|
169
|
+
hrefFor={props.hrefFor}
|
|
170
|
+
/>
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const indicator = (key: string): JSX.Element => (
|
|
174
|
+
<span aria-hidden="true" class={styles['indicator']}>
|
|
175
|
+
{ariaSortFor(props.sort, key) === 'ascending'
|
|
176
|
+
? '▲'
|
|
177
|
+
: ariaSortFor(props.sort, key) === 'descending'
|
|
178
|
+
? '▼'
|
|
179
|
+
: '↕'}
|
|
180
|
+
</span>
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* One mode per table, as the pager has: an anchor at the next state's URL when the table is
|
|
185
|
+
* linked, a button that calls back when it is not. A linked table with no `sortHrefFor` gets the
|
|
186
|
+
* header text alone — a `<button>` on a page that never hydrates is a control that does nothing.
|
|
187
|
+
*/
|
|
188
|
+
const sortControl = (column: Column<Row>): JSX.Element => {
|
|
189
|
+
if (column.sortable !== true) return column.header;
|
|
190
|
+
const label = `${column.header}: ${sortLabel(column.key)}`;
|
|
191
|
+
if (props.hrefFor !== undefined) {
|
|
192
|
+
const sortHrefFor = props.sortHrefFor;
|
|
193
|
+
if (sortHrefFor === undefined) return column.header;
|
|
194
|
+
return (
|
|
195
|
+
<a
|
|
196
|
+
class={styles['sortButton']}
|
|
197
|
+
aria-label={label}
|
|
198
|
+
href={sortHrefFor(nextSortState(props.sort, column.key))}
|
|
199
|
+
>
|
|
200
|
+
{column.header}
|
|
201
|
+
{indicator(column.key)}
|
|
202
|
+
</a>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
const onSortChange = props.onSortChange;
|
|
206
|
+
return (
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
class={styles['sortButton']}
|
|
210
|
+
aria-label={label}
|
|
211
|
+
onClick={() => onSortChange?.(nextSortState(props.sort, column.key))}
|
|
212
|
+
>
|
|
213
|
+
{column.header}
|
|
214
|
+
{indicator(column.key)}
|
|
215
|
+
</button>
|
|
216
|
+
);
|
|
217
|
+
};
|
|
218
|
+
|
|
134
219
|
return (
|
|
135
220
|
<div class={cx(styles['wrap'], props.class)}>
|
|
136
221
|
<Table
|
|
@@ -147,25 +232,7 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
|
|
|
147
232
|
aria-sort={ariaSortFor(props.sort, column.key)}
|
|
148
233
|
class={column.numeric === true ? styles['numeric'] : undefined}
|
|
149
234
|
>
|
|
150
|
-
{column
|
|
151
|
-
<button
|
|
152
|
-
type="button"
|
|
153
|
-
class={styles['sortButton']}
|
|
154
|
-
aria-label={`${column.header}: ${sortLabel(column.key)}`}
|
|
155
|
-
onClick={() => props.onSortChange?.(nextSortState(props.sort, column.key))}
|
|
156
|
-
>
|
|
157
|
-
{column.header}
|
|
158
|
-
<span aria-hidden="true" class={styles['indicator']}>
|
|
159
|
-
{ariaSortFor(props.sort, column.key) === 'ascending'
|
|
160
|
-
? '▲'
|
|
161
|
-
: ariaSortFor(props.sort, column.key) === 'descending'
|
|
162
|
-
? '▼'
|
|
163
|
-
: '↕'}
|
|
164
|
-
</span>
|
|
165
|
-
</button>
|
|
166
|
-
) : (
|
|
167
|
-
column.header
|
|
168
|
-
)}
|
|
235
|
+
{sortControl(column)}
|
|
169
236
|
</th>
|
|
170
237
|
))}
|
|
171
238
|
</tr>
|
|
@@ -177,13 +244,7 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
|
|
|
177
244
|
{body()}
|
|
178
245
|
</tbody>
|
|
179
246
|
</Table>
|
|
180
|
-
{props.nextCursor === undefined && props.prevCursor === undefined ? null : (
|
|
181
|
-
<Pagination
|
|
182
|
-
nextCursor={props.nextCursor}
|
|
183
|
-
prevCursor={props.prevCursor}
|
|
184
|
-
onCursor={props.onCursor}
|
|
185
|
-
/>
|
|
186
|
-
)}
|
|
247
|
+
{props.nextCursor === undefined && props.prevCursor === undefined ? null : pager()}
|
|
187
248
|
</div>
|
|
188
249
|
);
|
|
189
250
|
}
|
|
@@ -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'] {
|
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}
|