@ruckc/truss-ui 1.3.0 → 1.4.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/README.md CHANGED
@@ -7,7 +7,7 @@ A **Svelte 5 / SvelteKit design system** that blends Material 3's tonal surfaces
7
7
 
8
8
  **Docs and live demo: <https://truss.ruck.io>**
9
9
 
10
- - **69 components**: forms and pickers (no native `<select>`, date or time inputs), data tables with virtual scrolling, trees, menus, dialogs, an application shell and more.
10
+ - **90+ components**: forms and pickers (no native `<select>`, date or time inputs), data tables with virtual scrolling, trees, menus, dialogs, an application shell and more.
11
11
  - **Zero runtime dependencies** in the core (peer: `svelte`). Fonts are bundled and served by *your* build.
12
12
  - **Accessible by construction**: names, ARIA, focus and complete keyboard maps, audited for every combination of every component's options.
13
13
  - **Themeable**: one set of `--tr-*` CSS tokens, light and dark, drives everything, including charts, graphs and maps.
@@ -102,6 +102,8 @@ Every push to `main` that passes CI is released automatically: the version is wo
102
102
 
103
103
  Commits that don't follow the format (no `type:` prefix) don't trigger a release on their own. Publishing uses npm [trusted publishing](https://docs.npmjs.com/trusted-publishers) (OIDC from the `ci.yml` workflow), so no npm token is stored anywhere. Each package's trusted publisher on npmjs.com must be for workflow `ci.yml` with the **`npm publish`** action allowed (the default is only `npm stage publish`, which npm rejects with `403 OIDC permission denied for this action`).
104
104
 
105
+ A new version can take several minutes to become installable after the release job publishes it, so an install that returns 404 right after a release should be retried rather than republished.
106
+
105
107
  ## Licence
106
108
 
107
109
  MIT © Curtis Ruck. The bundled Inter and JetBrains Mono fonts are under the SIL Open Font License 1.1.
@@ -133,6 +133,7 @@
133
133
  <style>
134
134
  .tr-btn {
135
135
  --c: var(--tr-primary);
136
+ --ct: var(--tr-primary-text, var(--tr-primary));
136
137
  --on: var(--tr-on-primary);
137
138
  --cc: var(--tr-primary-container);
138
139
  --on-cc: var(--tr-on-primary-container);
@@ -160,11 +161,11 @@
160
161
  .tr-btn[data-size='lg'] { height: var(--tr-control-h-lg); min-width: var(--tr-control-h-lg); padding: 0 16px; font-size: var(--tr-text-lg); }
161
162
  .tr-btn[data-icon-only='true'] { padding: 0; }
162
163
 
163
- .tr-btn[data-intent='neutral'] { --c: var(--tr-on-surface); --on: var(--tr-surface); --cc: var(--tr-surface-container-high); --on-cc: var(--tr-on-surface); }
164
- .tr-btn[data-intent='secondary'] { --c: var(--tr-secondary); --on: var(--tr-on-secondary); --cc: var(--tr-secondary-container); --on-cc: var(--tr-on-secondary-container); }
165
- .tr-btn[data-intent='success'] { --c: var(--tr-success); --on: var(--tr-on-success); --cc: var(--tr-success-container); --on-cc: var(--tr-on-success-container); }
166
- .tr-btn[data-intent='warning'] { --c: var(--tr-warning); --on: var(--tr-on-warning); --cc: var(--tr-warning-container); --on-cc: var(--tr-on-warning-container); }
167
- .tr-btn[data-intent='danger'] { --c: var(--tr-danger); --on: var(--tr-on-danger); --cc: var(--tr-danger-container); --on-cc: var(--tr-on-danger-container); }
164
+ .tr-btn[data-intent='neutral'] { --c: var(--tr-on-surface); --ct: var(--tr-on-surface); --on: var(--tr-surface); --cc: var(--tr-surface-container-high); --on-cc: var(--tr-on-surface); }
165
+ .tr-btn[data-intent='secondary'] { --c: var(--tr-secondary); --ct: var(--tr-secondary-text, var(--tr-secondary)); --on: var(--tr-on-secondary); --cc: var(--tr-secondary-container); --on-cc: var(--tr-on-secondary-container); }
166
+ .tr-btn[data-intent='success'] { --c: var(--tr-success); --ct: var(--tr-success); --on: var(--tr-on-success); --cc: var(--tr-success-container); --on-cc: var(--tr-on-success-container); }
167
+ .tr-btn[data-intent='warning'] { --c: var(--tr-warning); --ct: var(--tr-warning); --on: var(--tr-on-warning); --cc: var(--tr-warning-container); --on-cc: var(--tr-on-warning-container); }
168
+ .tr-btn[data-intent='danger'] { --c: var(--tr-danger); --ct: var(--tr-danger); --on: var(--tr-on-danger); --cc: var(--tr-danger-container); --on-cc: var(--tr-on-danger-container); }
168
169
 
169
170
  /* Primary filled: solid, high contrast */
170
171
  .tr-btn[data-variant='filled'] { background: var(--c); color: var(--on); box-shadow: var(--tr-elevation-1); }
@@ -173,9 +174,9 @@
173
174
  .tr-btn[data-variant='tonal'] { background: var(--cc); color: var(--on-cc); }
174
175
  .tr-btn[data-variant='tonal']:hover:not(:disabled) { background: color-mix(in srgb, var(--on-cc) 10%, var(--cc)); }
175
176
  /* Outlined: precise 1px border */
176
- .tr-btn[data-variant='outlined'] { border-color: var(--tr-outline); color: var(--c); background: transparent; }
177
+ .tr-btn[data-variant='outlined'] { border-color: var(--tr-outline); color: var(--ct); background: transparent; }
177
178
  .tr-btn[data-variant='outlined']:hover:not(:disabled) { background: color-mix(in srgb, var(--c) 8%, transparent); }
178
- .tr-btn[data-variant='minimal'] { color: var(--c); background: transparent; }
179
+ .tr-btn[data-variant='minimal'] { color: var(--ct); background: transparent; }
179
180
  .tr-btn[data-variant='minimal']:hover:not(:disabled) { background: color-mix(in srgb, var(--c) 10%, transparent); }
180
181
 
181
182
  .tr-btn:active:not(:disabled) { transform: scale(0.98); }
@@ -3,6 +3,7 @@
3
3
  import type { HTMLAnchorAttributes, HTMLAttributes, HTMLButtonAttributes } from 'svelte/elements';
4
4
 
5
5
  type Base = {
6
+ /** Padding of the body only (the `children` between header and footer): `md` (default) is 16px, `sm` is 8px 12px, `none` removes it so the content can supply its own spacing or a media-only card has none. The header, footer and actions keep their own padding. */
6
7
  padding?: 'none' | 'sm' | 'md';
7
8
  elevation?: 0 | 1 | 2 | 3;
8
9
  /** Edge-to-edge content above the header and body (an image, video or map); clipped by the card's rounded corners. */
@@ -1,6 +1,7 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { HTMLAnchorAttributes, HTMLAttributes, HTMLButtonAttributes } from 'svelte/elements';
3
3
  type Base = {
4
+ /** Padding of the body only (the `children` between header and footer): `md` (default) is 16px, `sm` is 8px 12px, `none` removes it so the content can supply its own spacing or a media-only card has none. The header, footer and actions keep their own padding. */
4
5
  padding?: 'none' | 'sm' | 'md';
5
6
  elevation?: 0 | 1 | 2 | 3;
6
7
  /** Edge-to-edge content above the header and body (an image, video or map); clipped by the card's rounded corners. */
@@ -739,7 +739,7 @@
739
739
  <style>
740
740
  .bar { display: flex; align-items: center; gap: 8px; min-height: 32px; margin-bottom: 6px; }
741
741
  .grow { flex: 1; }
742
- .sel { font-size: var(--tr-text-sm); font-weight: 600; color: var(--tr-primary); min-width: 80px; }
742
+ .sel { font-size: var(--tr-text-sm); font-weight: 600; color: var(--tr-primary-text, var(--tr-primary)); min-width: 80px; }
743
743
  .rows { font-size: var(--tr-text-sm); color: var(--tr-on-surface-variant); font-variant-numeric: tabular-nums; }
744
744
  .colmenu { list-style: none; margin: 0; padding: 8px 12px; display: grid; gap: 4px; white-space: nowrap; }
745
745
  .chk { width: 40px; padding: 0 0 0 10px !important; }
@@ -784,7 +784,7 @@
784
784
  .hi { display: flex; align-items: center; gap: 8px; width: 100%; height: var(--tr-control-h); padding: 0 8px; border: 0; border-radius: var(--tr-radius-sm); background: none; color: var(--tr-on-surface); font: inherit; text-align: left; cursor: pointer; }
785
785
  .hi:hover:not(:disabled), .hi:focus-visible { outline: none; background: var(--tr-surface-container-highest); }
786
786
  .hi:disabled { opacity: 0.45; cursor: not-allowed; }
787
- .hi[data-on='true'] { color: var(--tr-primary); font-weight: 700; }
787
+ .hi[data-on='true'] { color: var(--tr-primary-text, var(--tr-primary)); font-weight: 700; }
788
788
  .fmenu.inline { padding: 0; max-height: 160px; }
789
789
 
790
790
  /* Filter row sits under the header, also frozen */
@@ -14,7 +14,7 @@
14
14
  <svelte:element this={as} class="eb {klass}" data-tone={tone} {...rest}>{@render children?.()}</svelte:element>
15
15
 
16
16
  <style>
17
- .eb { margin: 0; font-family: var(--tr-font-display, var(--tr-font-sans)); font-size: var(--tr-text-sm); font-weight: 700; line-height: 1.3; letter-spacing: 0.08em; text-transform: uppercase; color: var(--tr-primary); }
17
+ .eb { margin: 0; font-family: var(--tr-font-display, var(--tr-font-sans)); font-size: var(--tr-text-sm); font-weight: 700; line-height: 1.3; letter-spacing: 0.08em; text-transform: uppercase; color: var(--tr-primary-text, var(--tr-primary)); }
18
18
  .eb[data-tone='muted'] { color: var(--tr-on-surface-variant); }
19
19
  .eb[data-tone='default'] { color: var(--tr-on-surface); }
20
20
  </style>
@@ -126,7 +126,7 @@
126
126
  .zone:focus-visible { outline: none; border-color: var(--tr-primary); box-shadow: var(--tr-focus-ring); }
127
127
  .zone[aria-disabled='true'] { opacity: 0.5; cursor: not-allowed; }
128
128
  strong { color: var(--tr-on-surface); }
129
- .link { color: var(--tr-primary); font-weight: 600; text-decoration: underline; }
129
+ .link { color: var(--tr-primary-text, var(--tr-primary)); font-weight: 600; text-decoration: underline; }
130
130
  .ic { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; background: var(--tr-primary-container); color: var(--tr-on-primary-container); }
131
131
  .hint { font-size: var(--tr-text-sm); }
132
132
  .list { margin: 8px 0 0; padding: 0; list-style: none; display: grid; gap: 6px; }
@@ -6,8 +6,12 @@
6
6
  start,
7
7
  center,
8
8
  end,
9
+ brand,
10
+ meta,
11
+ links,
9
12
  children,
10
13
  variant = 'plain',
14
+ contained = false,
11
15
  class: klass,
12
16
  ...rest
13
17
  }: {
@@ -17,18 +21,31 @@
17
21
  center?: Snippet;
18
22
  /** Trailing region (e.g. social links). */
19
23
  end?: Snippet;
24
+ /** Identity (e.g. a logo) at the start. With `brand`, `meta` or `links`, `start`/`center`/`end` are ignored. */
25
+ brand?: Snippet;
26
+ /** Small print (e.g. copyright) stacked directly under `brand`. */
27
+ meta?: Snippet;
28
+ /** One or more link groups, right-aligned on the same row when wide. */
29
+ links?: Snippet;
20
30
  /** Free-form content; replaces the three regions. */
21
31
  children?: Snippet;
22
32
  /** `bordered` adds a top border. */
23
33
  variant?: 'plain' | 'bordered';
34
+ /** Align the content to the `Container` max-width and gutters while the background stays full-bleed. `true` is `lg`; sizes match `Container`. */
35
+ contained?: boolean | 'sm' | 'md' | 'lg';
24
36
  class?: string;
25
37
  } & Omit<HTMLAttributes<HTMLElement>, 'children' | 'class'> = $props();
26
38
  </script>
27
39
 
28
40
  <!-- A page-level <footer> is the contentinfo landmark; keep a single one per page. -->
29
- <footer {...rest} class={['ft', klass]} data-variant={variant}>
41
+ <footer {...rest} class={['ft', klass]} data-variant={variant} data-contained={contained === true ? 'lg' : contained || undefined}>
30
42
  {#if children}
31
43
  <div class="in free">{@render children()}</div>
44
+ {:else if brand || meta || links}
45
+ <div class="in sem">
46
+ {#if brand || meta}<div class="bm">{#if brand}{@render brand()}{/if}{#if meta}<div class="meta">{@render meta()}</div>{/if}</div>{/if}
47
+ {#if links}<div class="l">{@render links()}</div>{/if}
48
+ </div>
32
49
  {:else}
33
50
  <div class="in">
34
51
  <div class="s">{#if start}{@render start()}{/if}</div>
@@ -42,13 +59,23 @@
42
59
  .ft { container-type: inline-size; background: var(--tr-surface-container); color: var(--tr-on-surface-variant); font-size: var(--tr-text-sm); }
43
60
  .ft[data-variant='bordered'] { border-top: 1px solid var(--tr-outline-variant); }
44
61
  .in { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px 16px; padding: 16px var(--tr-gutter, clamp(16px, 4vw, 32px)); }
62
+ .ft[data-contained] { --_w: var(--tr-container-max); }
63
+ .ft[data-contained='sm'] { --_w: 40rem; }
64
+ .ft[data-contained='md'] { --_w: 56rem; }
65
+ .ft[data-contained] .in { padding-inline: max(var(--tr-gutter, 16px), calc((100% - var(--_w)) / 2 + var(--tr-gutter, 16px))); }
66
+ .in.sem { grid-template-columns: minmax(0, 1fr) minmax(0, auto); }
67
+ .bm { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; min-width: 0; }
68
+ .l { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px 24px; min-width: 0; }
45
69
  .in.free { display: block; }
46
70
  .s, .c, .e { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; min-width: 0; }
47
71
  .c { justify-content: center; }
48
72
  .e { justify-content: flex-end; }
49
73
  .s:empty, .c:empty, .e:empty { display: none; }
74
+ /* each region keeps its own column, so an empty centre doesn't pull `end` into the middle */
75
+ .s { grid-column: 1; } .c { grid-column: 2; } .e { grid-column: 3; }
50
76
  @container (max-width: 640px) {
51
77
  .in:not(.free) { grid-template-columns: minmax(0, 1fr); justify-items: start; }
52
- .c, .e { justify-content: flex-start; }
78
+ .s, .c, .e { grid-column: auto; }
79
+ .c, .e, .l { justify-content: flex-start; }
53
80
  }
54
81
  </style>
@@ -7,10 +7,18 @@ type $$ComponentProps = {
7
7
  center?: Snippet;
8
8
  /** Trailing region (e.g. social links). */
9
9
  end?: Snippet;
10
+ /** Identity (e.g. a logo) at the start. With `brand`, `meta` or `links`, `start`/`center`/`end` are ignored. */
11
+ brand?: Snippet;
12
+ /** Small print (e.g. copyright) stacked directly under `brand`. */
13
+ meta?: Snippet;
14
+ /** One or more link groups, right-aligned on the same row when wide. */
15
+ links?: Snippet;
10
16
  /** Free-form content; replaces the three regions. */
11
17
  children?: Snippet;
12
18
  /** `bordered` adds a top border. */
13
19
  variant?: 'plain' | 'bordered';
20
+ /** Align the content to the `Container` max-width and gutters while the background stays full-bleed. `true` is `lg`; sizes match `Container`. */
21
+ contained?: boolean | 'sm' | 'md' | 'lg';
14
22
  class?: string;
15
23
  } & Omit<HTMLAttributes<HTMLElement>, 'children' | 'class'>;
16
24
  declare const Footer: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -31,7 +31,7 @@
31
31
  </a>
32
32
 
33
33
  <style>
34
- .ln { color: var(--tr-primary); text-decoration: underline; text-underline-offset: 2px; text-decoration-thickness: 1px; border-radius: var(--tr-radius-sm); }
34
+ .ln { color: var(--tr-primary-text, var(--tr-primary)); text-decoration: underline; text-underline-offset: 2px; text-decoration-thickness: 1px; border-radius: var(--tr-radius-sm); }
35
35
  .ln:hover { text-decoration-thickness: 2px; }
36
36
  .ln:focus-visible { outline: 2px solid var(--tr-primary); outline-offset: 2px; }
37
37
  .ic { display: inline-flex; vertical-align: -0.15em; margin-left: 0.2em; }
@@ -0,0 +1,113 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+
5
+ /**
6
+ * A prose list: a real `ul` (or `ol` when `ordered`) with spacing and marker styling from tokens.
7
+ *
8
+ * Markers: `disc` (default) and `ordered` numbering use native `list-style-type`, so they are
9
+ * drawn by the browser. `check` and `dash` hide the native marker and draw one with `::before`.
10
+ * `none` removes it. Ordered lists always number their items; `marker="none"` removes the numbers.
11
+ * When the marker is removed or replaced (`check`, `dash`, `none`), `role="list"` is set so
12
+ * Safari and VoiceOver still announce the list.
13
+ *
14
+ * Customise with CSS custom properties on the list or any ancestor:
15
+ * `--tr-list-marker-color` (default `--tr-primary`) and `--tr-list-indent` (default `1.25rem`).
16
+ * The default marker colour is the primary token, which reaches at least 3:1 contrast against
17
+ * `--tr-surface` in both the light and the dark theme. A custom colour must meet that bar too.
18
+ */
19
+ let {
20
+ ordered = false,
21
+ marker = 'disc',
22
+ gap = 2,
23
+ size = 'md',
24
+ class: klass = '',
25
+ children,
26
+ ...rest
27
+ }: {
28
+ /** Render an `ol` with numbered items instead of a `ul`. */
29
+ ordered?: boolean;
30
+ /** Marker style. Ordered lists number their items, so only `none` changes them. */
31
+ marker?: 'disc' | 'check' | 'dash' | 'none';
32
+ /** Space between items, from the `--tr-space-*` scale. */
33
+ gap?: 1 | 2 | 3 | 4 | 5 | 6;
34
+ size?: 'sm' | 'md';
35
+ class?: string;
36
+ /** `ListItem`s, or plain `li` elements. */
37
+ children?: Snippet;
38
+ } & HTMLAttributes<HTMLUListElement | HTMLOListElement> = $props();
39
+
40
+ const effective = $derived(ordered ? (marker === 'none' ? 'none' : 'decimal') : marker);
41
+ const customMarker = $derived(effective === 'check' || effective === 'dash' || effective === 'none');
42
+ </script>
43
+
44
+ <svelte:element
45
+ this={ordered ? 'ol' : 'ul'}
46
+ class="list {klass}"
47
+ role={customMarker ? 'list' : undefined}
48
+ data-marker={effective}
49
+ data-size={size}
50
+ data-gap={gap}
51
+ {...rest}
52
+ >
53
+ {@render children?.()}
54
+ </svelte:element>
55
+
56
+ <style>
57
+ .list {
58
+ margin: 0;
59
+ padding: 0 0 0 var(--tr-list-indent, 1.25rem);
60
+ color: var(--tr-on-surface);
61
+ font-size: var(--tr-text-md);
62
+ line-height: 1.5;
63
+ list-style-position: outside;
64
+ }
65
+ .list[data-size='sm'] { font-size: var(--tr-text-sm); }
66
+
67
+ .list[data-marker='disc'] { list-style-type: disc; }
68
+ .list[data-marker='decimal'] { list-style-type: decimal; }
69
+ .list[data-marker='none'],
70
+ .list[data-marker='check'],
71
+ .list[data-marker='dash'] { list-style-type: none; }
72
+
73
+ /* the native marker takes the marker colour */
74
+ .list[data-marker='disc'] > :global(li)::marker,
75
+ .list[data-marker='decimal'] > :global(li)::marker {
76
+ color: var(--tr-list-marker-color, var(--tr-primary));
77
+ }
78
+
79
+ /* spacing: space between items, from the token scale */
80
+ .list > :global(li + li) { margin-block-start: var(--tr-list-gap, 0); }
81
+ .list[data-gap='1'] { --tr-list-gap: var(--tr-space-1); }
82
+ .list[data-gap='2'] { --tr-list-gap: var(--tr-space-2); }
83
+ .list[data-gap='3'] { --tr-list-gap: var(--tr-space-3); }
84
+ .list[data-gap='4'] { --tr-list-gap: var(--tr-space-4); }
85
+ .list[data-gap='5'] { --tr-list-gap: var(--tr-space-5); }
86
+ .list[data-gap='6'] { --tr-list-gap: var(--tr-space-6); }
87
+
88
+ /* drawn markers for check and dash */
89
+ .list > :global(li) { position: relative; }
90
+ .list[data-marker='check'] > :global(li)::before,
91
+ .list[data-marker='dash'] > :global(li)::before {
92
+ content: '';
93
+ position: absolute;
94
+ left: calc(-1 * var(--tr-list-indent, 1.25rem));
95
+ top: 0.3em;
96
+ width: 1em;
97
+ height: 1em;
98
+ background-color: var(--tr-list-marker-color, var(--tr-primary));
99
+ }
100
+ .list[data-marker='check'] > :global(li)::before {
101
+ width: 0.9em;
102
+ height: 0.9em;
103
+ top: 0.3em;
104
+ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 8.5l3.2 3L13 4.5' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat;
105
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 8.5l3.2 3L13 4.5' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat;
106
+ }
107
+ .list[data-marker='dash'] > :global(li)::before {
108
+ left: calc(-1 * var(--tr-list-indent, 1.25rem) + 0.1em);
109
+ top: 0.7em;
110
+ width: 0.8em;
111
+ height: 2px;
112
+ }
113
+ </style>
@@ -0,0 +1,17 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ type $$ComponentProps = {
4
+ /** Render an `ol` with numbered items instead of a `ul`. */
5
+ ordered?: boolean;
6
+ /** Marker style. Ordered lists number their items, so only `none` changes them. */
7
+ marker?: 'disc' | 'check' | 'dash' | 'none';
8
+ /** Space between items, from the `--tr-space-*` scale. */
9
+ gap?: 1 | 2 | 3 | 4 | 5 | 6;
10
+ size?: 'sm' | 'md';
11
+ class?: string;
12
+ /** `ListItem`s, or plain `li` elements. */
13
+ children?: Snippet;
14
+ } & HTMLAttributes<HTMLUListElement | HTMLOListElement>;
15
+ declare const List: import("svelte").Component<$$ComponentProps, {}, "">;
16
+ type List = ReturnType<typeof List>;
17
+ export default List;
@@ -0,0 +1,13 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLLiAttributes } from 'svelte/elements';
4
+
5
+ /** An item of a `List`. Plain `li` elements inside a `List` work too. */
6
+ let { class: klass = '', children, ...rest }: { class?: string; children?: Snippet } & HTMLLiAttributes = $props();
7
+ </script>
8
+
9
+ <li class="item {klass}" {...rest}>{@render children?.()}</li>
10
+
11
+ <style>
12
+ .item { min-width: 0; overflow-wrap: anywhere; }
13
+ </style>
@@ -0,0 +1,9 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLLiAttributes } from 'svelte/elements';
3
+ type $$ComponentProps = {
4
+ class?: string;
5
+ children?: Snippet;
6
+ } & HTMLLiAttributes;
7
+ declare const ListItem: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ type ListItem = ReturnType<typeof ListItem>;
9
+ export default ListItem;
@@ -13,6 +13,8 @@
13
13
  elevated = true,
14
14
  collapseBelow,
15
15
  menu,
16
+ menuAlign = 'center',
17
+ contained = false,
16
18
  menuLabel = 'Menu',
17
19
  class: klass,
18
20
  ...rest
@@ -27,8 +29,12 @@
27
29
  elevated?: boolean;
28
30
  /** Viewport width (px, or any CSS length such as `48rem`) at or below which `menu` moves into a drawer behind a menu button. */
29
31
  collapseBelow?: string | number;
30
- /** Primary navigation (usually a `<NavList>`). Shown in the centre region when wide, in a drawer when collapsed. */
32
+ /** Primary navigation (usually a `<NavList>`). Shown in the centre region (or at the end, see `menuAlign`) when wide, in a drawer when collapsed. */
31
33
  menu?: Snippet;
34
+ /** Where `menu` sits when the bar is wide: the centre region, or at the trailing edge just before the `end` content. */
35
+ menuAlign?: 'center' | 'end';
36
+ /** Align the content to the `Container` max-width and gutters while the background stays full-bleed. `true` is `lg`; sizes match `Container`. */
37
+ contained?: boolean | 'sm' | 'md' | 'lg';
32
38
  /** Accessible name of the menu button and the drawer title. */
33
39
  menuLabel?: string;
34
40
  class?: string;
@@ -63,10 +69,11 @@
63
69
  });
64
70
  </script>
65
71
 
66
- <header {...rest} class={['nb', klass]} data-sticky={sticky} data-variant={variant} data-elevated={elevated}>
72
+ <header {...rest} class={['nb', klass]} data-sticky={sticky} data-variant={variant} data-elevated={elevated} data-contained={contained === true ? 'lg' : contained || undefined}>
67
73
  <div class="s">{#if start}{@render start()}{/if}</div>
68
- <div class="c">{#if menu && !collapsed}{@render menu()}{/if}{#if center}{@render center()}{/if}</div>
74
+ <div class="c">{#if menu && !collapsed && menuAlign !== 'end'}{@render menu()}{/if}{#if center}{@render center()}{/if}</div>
69
75
  <div class="e">
76
+ {#if menu && !collapsed && menuAlign === 'end'}{@render menu()}{/if}
70
77
  {#if end}{@render end()}{/if}
71
78
  {#if menu && collapsed}
72
79
  <span class="mb" bind:this={trigger}>
@@ -85,6 +92,9 @@
85
92
 
86
93
  <style>
87
94
  .nb { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; height: var(--tr-navbar-h, 48px); padding: 0 12px; background: var(--tr-surface-container-low); border-bottom: 1px solid var(--tr-outline-variant); box-shadow: var(--tr-elevation-1); z-index: 20; font-family: var(--tr-font-display, var(--tr-font-sans)); letter-spacing: var(--tr-display-tracking, normal); text-transform: var(--tr-display-transform, none); }
95
+ .nb[data-contained] { --_w: var(--tr-container-max); padding-inline: max(var(--tr-gutter), calc((100% - var(--_w)) / 2 + var(--tr-gutter))); }
96
+ .nb[data-contained='sm'] { --_w: 40rem; }
97
+ .nb[data-contained='md'] { --_w: 56rem; }
88
98
  .nb[data-sticky='true'] { position: sticky; top: 0; }
89
99
  .nb[data-elevated='false'] { box-shadow: none; }
90
100
  /* Without backdrop-filter the surface stays nearly opaque so content beneath cannot hurt legibility. */
@@ -11,8 +11,12 @@ type $$ComponentProps = {
11
11
  elevated?: boolean;
12
12
  /** Viewport width (px, or any CSS length such as `48rem`) at or below which `menu` moves into a drawer behind a menu button. */
13
13
  collapseBelow?: string | number;
14
- /** Primary navigation (usually a `<NavList>`). Shown in the centre region when wide, in a drawer when collapsed. */
14
+ /** Primary navigation (usually a `<NavList>`). Shown in the centre region (or at the end, see `menuAlign`) when wide, in a drawer when collapsed. */
15
15
  menu?: Snippet;
16
+ /** Where `menu` sits when the bar is wide: the centre region, or at the trailing edge just before the `end` content. */
17
+ menuAlign?: 'center' | 'end';
18
+ /** Align the content to the `Container` max-width and gutters while the background stays full-bleed. `true` is `lg`; sizes match `Container`. */
19
+ contained?: boolean | 'sm' | 'md' | 'lg';
16
20
  /** Accessible name of the menu button and the drawer title. */
17
21
  menuLabel?: string;
18
22
  class?: string;
@@ -198,7 +198,7 @@
198
198
  li { display: flex; align-items: center; gap: 8px; height: var(--tr-control-h); padding: 0 8px; border-radius: var(--tr-radius-sm); cursor: pointer; }
199
199
  li span { flex: 1; }
200
200
  li[data-active='true'] { background: var(--tr-surface-container-highest); }
201
- li[aria-selected='true'] { color: var(--tr-primary); font-weight: 600; }
201
+ li[aria-selected='true'] { color: var(--tr-primary-text, var(--tr-primary)); font-weight: 600; }
202
202
  li[aria-disabled='true'] { opacity: 0.45; cursor: not-allowed; }
203
203
  li.empty { color: var(--tr-on-surface-variant); cursor: default; }
204
204
  </style>
@@ -106,7 +106,7 @@
106
106
  .sl[data-disabled='true'] { opacity: 0.5; }
107
107
  .head { display: flex; justify-content: space-between; margin: 0 -8px 4px; }
108
108
  .head .tr-label { margin: 0; }
109
- .val { font: 600 var(--tr-text-sm) var(--tr-font-sans); font-variant-numeric: tabular-nums; color: var(--tr-primary); }
109
+ .val { font: 600 var(--tr-text-sm) var(--tr-font-sans); font-variant-numeric: tabular-nums; color: var(--tr-primary-text, var(--tr-primary)); }
110
110
  .track { position: relative; height: 24px; cursor: pointer; touch-action: none; }
111
111
  .sl[data-disabled='true'] .track { cursor: not-allowed; }
112
112
  .rail, .fill { position: absolute; top: 10px; height: 4px; border-radius: 2px; }
@@ -255,7 +255,7 @@
255
255
  [role='tab']:hover:not(:disabled) { background: var(--tr-surface-container-high); color: var(--tr-on-surface); }
256
256
  [role='tab']:disabled { opacity: 0.45; cursor: not-allowed; }
257
257
  [role='tab']:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--tr-primary); }
258
- [role='tab'][aria-selected='true'] { color: var(--tr-primary); }
258
+ [role='tab'][aria-selected='true'] { color: var(--tr-primary-text, var(--tr-primary)); }
259
259
  [role='tab'][aria-selected='true']::after { content: ''; position: absolute; left: 8px; right: 8px; bottom: -1px; height: 3px; border-radius: 3px 3px 0 0; background: var(--tr-primary); }
260
260
  .panel { padding: 16px 4px; outline: none; }
261
261
  .panel:focus-visible { box-shadow: var(--tr-focus-ring); border-radius: var(--tr-radius-sm); }
@@ -43,14 +43,14 @@
43
43
  </span>
44
44
 
45
45
  <style>
46
- .tag { --c: var(--tr-on-surface-variant); --cc: var(--tr-surface-container-high); --on: var(--tr-on-surface); display: inline-flex; align-items: center; gap: 4px; height: var(--tr-control-h-sm); padding: 0 8px; border-radius: var(--tr-radius-sm); background: var(--cc); color: var(--on); font: 600 var(--tr-text-sm) var(--tr-font-display); letter-spacing: var(--tr-display-tracking); text-transform: var(--tr-display-transform); white-space: nowrap; }
46
+ .tag { --c: var(--tr-on-surface-variant); --ct: var(--c); --cc: var(--tr-surface-container-high); --on: var(--tr-on-surface); display: inline-flex; align-items: center; gap: 4px; height: var(--tr-control-h-sm); padding: 0 8px; border-radius: var(--tr-radius-sm); background: var(--cc); color: var(--on); font: 600 var(--tr-text-sm) var(--tr-font-display); letter-spacing: var(--tr-display-tracking); text-transform: var(--tr-display-transform); white-space: nowrap; }
47
47
  .tag[data-size='sm'] { height: 20px; padding: 0 6px; font-size: var(--tr-text-xs); }
48
- .tag[data-intent='primary'] { --c: var(--tr-primary); --cc: var(--tr-primary-container); --on: var(--tr-on-primary-container); }
49
- .tag[data-intent='secondary'] { --c: var(--tr-secondary); --cc: var(--tr-secondary-container); --on: var(--tr-on-secondary-container); }
50
- .tag[data-intent='success'] { --c: var(--tr-success); --cc: var(--tr-success-container); --on: var(--tr-on-success-container); }
51
- .tag[data-intent='warning'] { --c: var(--tr-warning); --cc: var(--tr-warning-container); --on: var(--tr-on-warning-container); }
52
- .tag[data-intent='danger'] { --c: var(--tr-danger); --cc: var(--tr-danger-container); --on: var(--tr-on-danger-container); }
53
- .tag[data-variant='outlined'] { background: transparent; box-shadow: inset 0 0 0 1px var(--c); color: var(--c); }
48
+ .tag[data-intent='primary'] { --c: var(--tr-primary); --ct: var(--tr-primary-text, var(--tr-primary)); --cc: var(--tr-primary-container); --on: var(--tr-on-primary-container); }
49
+ .tag[data-intent='secondary'] { --c: var(--tr-secondary); --ct: var(--tr-secondary-text, var(--tr-secondary)); --cc: var(--tr-secondary-container); --on: var(--tr-on-secondary-container); }
50
+ .tag[data-intent='success'] { --c: var(--tr-success); --ct: var(--tr-success); --cc: var(--tr-success-container); --on: var(--tr-on-success-container); }
51
+ .tag[data-intent='warning'] { --c: var(--tr-warning); --ct: var(--tr-warning); --cc: var(--tr-warning-container); --on: var(--tr-on-warning-container); }
52
+ .tag[data-intent='danger'] { --c: var(--tr-danger); --ct: var(--tr-danger); --cc: var(--tr-danger-container); --on: var(--tr-on-danger-container); }
53
+ .tag[data-variant='outlined'] { background: transparent; box-shadow: inset 0 0 0 1px var(--c); color: var(--ct); }
54
54
  .main { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: 0; background: none; color: inherit; font: inherit; cursor: pointer; }
55
55
  .tag:has(.main:hover) { background: color-mix(in srgb, var(--on) 10%, var(--cc)); }
56
56
  .main:focus-visible, .x:focus-visible { outline: none; box-shadow: var(--tr-focus-ring); border-radius: var(--tr-radius-sm); }
@@ -27,7 +27,7 @@
27
27
  .tx[data-size='lg'] { font-size: var(--tr-text-lg); }
28
28
  .tx[data-size='xl'] { font-size: var(--tr-text-xl); }
29
29
  .tx[data-tone='muted'] { color: var(--tr-on-surface-variant); }
30
- .tx[data-tone='primary'] { color: var(--tr-primary); }
30
+ .tx[data-tone='primary'] { color: var(--tr-primary-text, var(--tr-primary)); }
31
31
  .tx[data-tone='success'] { color: var(--tr-success); }
32
32
  .tx[data-tone='warning'] { color: var(--tr-warning); }
33
33
  .tx[data-tone='danger'] { color: var(--tr-danger); }
package/dist/index.d.ts CHANGED
@@ -43,6 +43,8 @@ export { default as Image } from './components/Image.svelte';
43
43
  export { default as DescriptionList } from './components/DescriptionList.svelte';
44
44
  export { default as DescriptionTerm } from './components/DescriptionTerm.svelte';
45
45
  export { default as DescriptionDetail } from './components/DescriptionDetail.svelte';
46
+ export { default as List } from './components/List.svelte';
47
+ export { default as ListItem } from './components/ListItem.svelte';
46
48
  export { default as Container } from './components/Container.svelte';
47
49
  export { default as Stack } from './components/Stack.svelte';
48
50
  export { default as Cluster } from './components/Cluster.svelte';
package/dist/index.js CHANGED
@@ -43,6 +43,8 @@ export { default as Image } from './components/Image.svelte';
43
43
  export { default as DescriptionList } from './components/DescriptionList.svelte';
44
44
  export { default as DescriptionTerm } from './components/DescriptionTerm.svelte';
45
45
  export { default as DescriptionDetail } from './components/DescriptionDetail.svelte';
46
+ export { default as List } from './components/List.svelte';
47
+ export { default as ListItem } from './components/ListItem.svelte';
46
48
  export { default as Container } from './components/Container.svelte';
47
49
  export { default as Stack } from './components/Stack.svelte';
48
50
  export { default as Cluster } from './components/Cluster.svelte';
@@ -66,6 +66,7 @@
66
66
  --tr-dur: 160ms;
67
67
 
68
68
  /* Color roles — light */
69
+ /* --tr-primary-text / --tr-secondary-text are optional: unset, text follows the fill colour (also on a re-themed subtree). Set them when a brand fill is too light for small text. */
69
70
  --tr-primary: #1f5fbf;
70
71
  --tr-on-primary: #ffffff;
71
72
  --tr-primary-container: #d8e4ff;
package/package.json CHANGED
@@ -68,10 +68,11 @@
68
68
  "url": "git+https://github.com/ruckc/truss-ui.git"
69
69
  },
70
70
  "scripts": {
71
+ "check:contrast": "node scripts/check-contrast.mjs",
71
72
  "build": "npm run docs && vite build",
72
- "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
73
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json && node scripts/component-count.mjs --check",
73
74
  "dev": "vite dev",
74
- "docs": "node scripts/extract-api.mjs",
75
+ "docs": "node scripts/extract-api.mjs && node scripts/component-count.mjs",
75
76
  "package": "svelte-kit sync && svelte-package",
76
77
  "package:all": "npm run package && npm run package --workspaces",
77
78
  "preview": "vite preview",
@@ -86,7 +87,7 @@
86
87
  "svelte": "./dist/index.js",
87
88
  "type": "module",
88
89
  "types": "./dist/index.d.ts",
89
- "version": "1.3.0",
90
+ "version": "1.4.0",
90
91
  "workspaces": [
91
92
  "packages/*"
92
93
  ]