@ultimat3/ui 22.14.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.
Files changed (47) hide show
  1. package/CATALOG.md +43 -12
  2. package/CLAUDE.md +13 -49
  3. package/README.md +59 -4
  4. package/package.json +6 -5
  5. package/src/catalog/parse-component.ts +85 -12
  6. package/src/catalog/render-catalog.ts +21 -1
  7. package/src/components/Avatar.module.scss +1 -1
  8. package/src/components/BarChart.module.scss +12 -1
  9. package/src/components/BarChart.tsx +41 -15
  10. package/src/components/Button.tsx +3 -8
  11. package/src/components/Card.module.scss +1 -1
  12. package/src/components/Checkbox.module.scss +4 -4
  13. package/src/components/Checkbox.tsx +6 -0
  14. package/src/components/CommandPalette.module.scss +1 -1
  15. package/src/components/DataTable.module.scss +2 -0
  16. package/src/components/DataTable.tsx +90 -29
  17. package/src/components/Dialog.module.scss +2 -2
  18. package/src/components/Dropzone.module.scss +3 -3
  19. package/src/components/FileInput.module.scss +1 -1
  20. package/src/components/Form.module.scss +2 -2
  21. package/src/components/Input.module.scss +1 -1
  22. package/src/components/Link.tsx +64 -12
  23. package/src/components/Pagination.tsx +89 -37
  24. package/src/components/Radio.module.scss +2 -2
  25. package/src/components/Select.module.scss +3 -3
  26. package/src/components/Skeleton.module.scss +1 -1
  27. package/src/components/Spinner.module.scss +3 -3
  28. package/src/components/Switch.module.scss +2 -2
  29. package/src/components/Table.module.scss +2 -2
  30. package/src/components/Tabs.module.scss +5 -5
  31. package/src/components/ThemeToggle.module.scss +1 -1
  32. package/src/components/Toast.module.scss +2 -2
  33. package/src/components/Tooltip.module.scss +1 -1
  34. package/src/components/bar-chart-view.ts +29 -3
  35. package/src/components/button-classes.ts +25 -0
  36. package/src/components/link-classes.ts +38 -0
  37. package/src/components/link-target.ts +13 -0
  38. package/src/index.ts +13 -3
  39. package/src/sass-probe.ts +55 -0
  40. package/src/tokens/_index.scss +3 -1
  41. package/src/tokens/_mixins.scss +56 -8
  42. package/src/tokens/_motion.scss +4 -0
  43. package/src/tokens/_stroke.scss +20 -0
  44. package/src/tokens/_units.scss +47 -0
  45. package/src/tokens/reset.scss +4 -4
  46. package/src/tokens/theme.scss +2 -0
  47. package/src/tokens/tokens.ts +6 -0
@@ -1,4 +1,5 @@
1
- // A bar chart of one series — clicks per day, signups per week — as static SVG. No charting
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
- <rect
49
- class={cx(styles['bar'], highlight() && index === lastIndex() && styles['last'])}
50
- x={rect.x}
51
- y={rect.y}
52
- width={rect.width}
53
- height={rect.height}
54
- rx={2}
55
- data-bar="true"
56
- >
57
- <title>
58
- {point.key}: {point.value}
59
- </title>
60
- </rect>
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
- class={cx(
70
- styles['button'],
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}
@@ -17,7 +17,7 @@
17
17
 
18
18
  &:hover {
19
19
  border-color: t.role('accent', 0.4);
20
- transform: translateY(-2px);
20
+ transform: translateY(t.rem(-2px));
21
21
  box-shadow: t.shadow(md);
22
22
  }
23
23
 
@@ -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: 2px solid t.role('accent-fg');
38
- border-block-end: 2px solid t.role('accent-fg');
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: 2px solid t.role('accent');
70
- outline-offset: 2px;
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(-4px) scale(0.98);
24
+ transform: translateX(-50%) translateY(calc(-1 * #{t.space(1)})) scale(0.98);
25
25
  opacity: 0;
26
26
  visibility: hidden;
27
27
  transition:
@@ -20,6 +20,8 @@
20
20
  border-radius: t.radius(sm);
21
21
  color: inherit;
22
22
  font: inherit;
23
+ // The link mode renders this class on an anchor: a sort header reads as a header, not a link.
24
+ text-decoration: none;
23
25
  cursor: pointer;
24
26
 
25
27
  &:hover {
@@ -33,13 +33,12 @@ export interface Column<Row> {
33
33
  width?: string | undefined;
34
34
  }
35
35
 
36
- export interface DataTableProps<Row> {
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.sortable === true ? (
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 8px;
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: 2px dashed t.role('line');
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: 2px solid t.role('accent');
19
- outline-offset: 2px;
18
+ outline: t.stroke(thick) solid t.role('accent');
19
+ outline-offset: t.stroke(thick);
20
20
  }
21
21
 
22
22
  &[data-over='true'] {
@@ -8,7 +8,7 @@
8
8
 
9
9
  &:focus-within {
10
10
  border-color: t.role('accent');
11
- outline: 2px solid t.role('accent');
11
+ outline: t.stroke(thick) solid t.role('accent');
12
12
  outline-offset: 1px;
13
13
  }
14
14
 
@@ -7,8 +7,8 @@
7
7
  }
8
8
 
9
9
  .summary:focus {
10
- outline: 2px solid t.role('danger');
11
- outline-offset: 4px;
10
+ outline: t.stroke(thick) solid t.role('danger');
11
+ outline-offset: t.space(1);
12
12
  }
13
13
 
14
14
  .actions {
@@ -8,7 +8,7 @@
8
8
 
9
9
  &:focus-within {
10
10
  border-color: t.role('accent');
11
- outline: 2px solid t.role('accent');
11
+ outline: t.stroke(thick) solid t.role('accent');
12
12
  outline-offset: 1px;
13
13
  }
14
14
 
@@ -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 { linkTarget } from './link-target';
9
+ import { linkClasses } from './link-classes';
10
+ import { linkRel, linkTarget } from './link-target';
11
+ import type { ButtonVariant, Size, Tone } from './variants';
8
12
 
9
- export interface LinkProps {
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
- underline?: 'always' | 'hover' | 'none' | undefined;
16
- tone?: 'accent' | 'inherit' | undefined;
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() ? 'noopener noreferrer' : undefined}
93
+ rel={linkRel(isExternal(), props.rel)}
37
94
  aria-current={props['aria-current']}
38
- class={cx(
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
- {props.children}
98
+ {content()}
47
99
  {isExternal() && props.externalHint !== undefined ? (
48
100
  <span class={styles['hint']}>{props.externalHint}</span>
49
101
  ) : null}