@dorsk/tsumikit 0.3.0 → 0.5.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.
@@ -1,12 +1,22 @@
1
1
  <script lang="ts">
2
2
  // Progress bar. Determinate when `value` is a number (0..max); omit `value`
3
- // for an indeterminate animation. Uses role="progressbar" with the right
4
- // aria-value* attributes. Token-styled.
3
+ // (or pass `indeterminate`) for an indeterminate animation. Uses
4
+ // role="progressbar" with the right aria-value* attributes. Token-styled.
5
+ //
6
+ // Variants (all token-driven, composable):
7
+ // size='sm' — thin track (~5px) for inline download/health rows.
8
+ // gradient — accent→teal fill (storage meter / library-health bars).
9
+ // striped — diagonal stripes; animated while in-flight (unpacking).
10
+ // indeterminate — explicit "scanning"/"importing" mode without a percentage.
5
11
  let {
6
12
  value,
7
13
  max = 100,
8
14
  label,
9
15
  tone = 'accent',
16
+ size = 'md',
17
+ gradient = false,
18
+ striped = false,
19
+ indeterminate: indeterminateProp = false,
10
20
  class: klass = ''
11
21
  }: {
12
22
  value?: number;
@@ -15,16 +25,26 @@
15
25
  // Fill colour. `accent` is the default brand fill; the semantic tones
16
26
  // retint the bar for severity (e.g. usage meters going warm/hot).
17
27
  tone?: 'accent' | 'success' | 'warn' | 'danger';
28
+ // Track height. `sm` is a thin ~5px track for inline rows.
29
+ size?: 'sm' | 'md';
30
+ // Accent→teal gradient fill (overrides the flat tone colour).
31
+ gradient?: boolean;
32
+ // Diagonal stripes; animated in indeterminate mode.
33
+ striped?: boolean;
34
+ // Force indeterminate mode even when a `value` is supplied.
35
+ indeterminate?: boolean;
18
36
  class?: string;
19
37
  } = $props();
20
38
 
21
39
  const pct = $derived(value == null ? 0 : Math.max(0, Math.min(100, (value / max) * 100)));
22
- const indeterminate = $derived(value == null);
40
+ const indeterminate = $derived(indeterminateProp || value == null);
23
41
  </script>
24
42
 
25
43
  <div
26
- class="progress tone-{tone} {klass}"
44
+ class="progress tone-{tone} size-{size} {klass}"
27
45
  class:indeterminate
46
+ class:gradient
47
+ class:striped
28
48
  role="progressbar"
29
49
  aria-label={label}
30
50
  aria-valuemin={indeterminate ? undefined : 0}
@@ -42,6 +62,9 @@
42
62
  border-radius: var(--r-pill);
43
63
  overflow: hidden;
44
64
  }
65
+ .progress.size-sm {
66
+ height: 0.3125rem;
67
+ }
45
68
  .bar {
46
69
  height: 100%;
47
70
  background: var(--fill, var(--accent));
@@ -57,11 +80,52 @@
57
80
  .tone-danger {
58
81
  --fill: var(--danger);
59
82
  }
83
+ .progress.gradient .bar {
84
+ background: linear-gradient(90deg, var(--fill, var(--accent)), var(--c-teal));
85
+ }
86
+ .progress.striped .bar {
87
+ background-image: linear-gradient(
88
+ 45deg,
89
+ rgba(255, 255, 255, 0.18) 25%,
90
+ transparent 25%,
91
+ transparent 50%,
92
+ rgba(255, 255, 255, 0.18) 50%,
93
+ rgba(255, 255, 255, 0.18) 75%,
94
+ transparent 75%,
95
+ transparent
96
+ );
97
+ background-size: 1rem 1rem;
98
+ }
99
+ .progress.gradient.striped .bar {
100
+ background-image:
101
+ linear-gradient(
102
+ 45deg,
103
+ rgba(255, 255, 255, 0.18) 25%,
104
+ transparent 25%,
105
+ transparent 50%,
106
+ rgba(255, 255, 255, 0.18) 50%,
107
+ rgba(255, 255, 255, 0.18) 75%,
108
+ transparent 75%,
109
+ transparent
110
+ ),
111
+ linear-gradient(90deg, var(--fill, var(--accent)), var(--c-teal));
112
+ background-size:
113
+ 1rem 1rem,
114
+ 100% 100%;
115
+ }
60
116
  .progress.indeterminate .bar {
61
117
  width: 40%;
62
- animation: progress-slide 1.1s ease-in-out infinite;
118
+ animation: kt-progress-slide 1.1s ease-in-out infinite;
63
119
  }
64
- @keyframes progress-slide {
120
+ .progress.striped .bar {
121
+ animation: kt-stripe 0.6s linear infinite;
122
+ }
123
+ .progress.striped.indeterminate .bar {
124
+ animation:
125
+ kt-progress-slide 1.1s ease-in-out infinite,
126
+ kt-stripe 0.6s linear infinite;
127
+ }
128
+ @keyframes kt-progress-slide {
65
129
  0% {
66
130
  transform: translateX(-110%);
67
131
  }
@@ -69,4 +133,19 @@
69
133
  transform: translateX(310%);
70
134
  }
71
135
  }
136
+ @keyframes kt-stripe {
137
+ 0% {
138
+ background-position: 0 0;
139
+ }
140
+ 100% {
141
+ background-position: 1rem 0;
142
+ }
143
+ }
144
+ @media (prefers-reduced-motion: reduce) {
145
+ .progress.indeterminate .bar,
146
+ .progress.striped .bar,
147
+ .progress.striped.indeterminate .bar {
148
+ animation: none;
149
+ }
150
+ }
72
151
  </style>
@@ -3,6 +3,10 @@ type $$ComponentProps = {
3
3
  max?: number;
4
4
  label?: string;
5
5
  tone?: 'accent' | 'success' | 'warn' | 'danger';
6
+ size?: 'sm' | 'md';
7
+ gradient?: boolean;
8
+ striped?: boolean;
9
+ indeterminate?: boolean;
6
10
  class?: string;
7
11
  };
8
12
  declare const Progress: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -1,13 +1,17 @@
1
1
  <script lang="ts">
2
- // Sidebar navigation item: icon + label. When the surrounding `sidebar`
3
- // query container gets narrow (≤ 8rem e.g. the user drags AppShell's
4
- // resizable sidebar down to a rail) the label hides and the icon centers,
5
- // purely in CSS. `title`/`aria-label` carry the name so the icon-only state
6
- // stays accessible and shows a tooltip. Renders an <a> when `href` is set,
7
- // else a <button>.
2
+ // Sidebar navigation item: icon + label, with an optional trailing count
3
+ // `badge`. When the surrounding `sidebar` query container gets narrow
4
+ // (≤ 8rem — e.g. the user drags AppShell's resizable sidebar down to a rail)
5
+ // the label hides and the icon centers, purely in CSS; a non-empty badge then
6
+ // collapses to a small dot indicator so the count cue survives the rail.
7
+ // `title`/`aria-label` carry the name so the icon-only state stays accessible
8
+ // and shows a tooltip. Renders an <a> when `href` is set, else a <button>.
8
9
  import type { Snippet } from 'svelte';
9
10
  import Icon from '../atoms/Icon.svelte';
10
11
  import type { IconName } from '../atoms/Icon.svelte';
12
+ import Badge from '../atoms/Badge.svelte';
13
+
14
+ type BadgeTone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
11
15
 
12
16
  let {
13
17
  icon,
@@ -15,6 +19,8 @@
15
19
  href,
16
20
  active = false,
17
21
  onclick,
22
+ badge,
23
+ badgeTone = 'neutral',
18
24
  children,
19
25
  ...rest
20
26
  }: {
@@ -23,9 +29,17 @@
23
29
  href?: string;
24
30
  active?: boolean;
25
31
  onclick?: (e: MouseEvent) => void;
32
+ /** Optional trailing count pill (e.g. unread / missing items). When the
33
+ * item is `active` it adopts the accent fill; otherwise it uses `badgeTone`. */
34
+ badge?: string | number;
35
+ /** Tone for the trailing badge when the item is not active. */
36
+ badgeTone?: BadgeTone;
26
37
  children?: Snippet;
27
38
  [key: string]: unknown;
28
39
  } = $props();
40
+
41
+ // Treat 0 / '' as "no badge" so callers can pass a raw count without guarding.
42
+ const hasBadge = $derived(badge !== undefined && badge !== null && badge !== '' && badge !== 0);
29
43
  </script>
30
44
 
31
45
  <svelte:element
@@ -42,11 +56,21 @@
42
56
  >
43
57
  <Icon name={icon} size={18} />
44
58
  <span class="nav-label">{label}</span>
45
- {#if children}<span class="nav-trail">{@render children()}</span>{/if}
59
+ {#if hasBadge}
60
+ <span class="nav-trail">
61
+ <Badge size="sm" tone={active ? 'neutral' : badgeTone} active={active}>{badge}</Badge>
62
+ </span>
63
+ <!-- Rail fallback: the pill is hidden by the container query below, this dot
64
+ takes over so the "has items" cue still reads in the icon rail. -->
65
+ <span class="nav-dot" class:active aria-hidden="true"></span>
66
+ {:else if children}
67
+ <span class="nav-trail">{@render children()}</span>
68
+ {/if}
46
69
  </svelte:element>
47
70
 
48
71
  <style>
49
72
  .nav-item {
73
+ position: relative;
50
74
  display: flex;
51
75
  align-items: center;
52
76
  gap: var(--sp-2);
@@ -74,14 +98,36 @@
74
98
  background: color-mix(in srgb, var(--accent) 14%, transparent);
75
99
  color: var(--accent);
76
100
  }
101
+ /* Active-route accent inset: a left rail marker that survives the icon-rail
102
+ collapse (the tint background does too, but the marker reads at a glance). */
103
+ .nav-item.active::before {
104
+ content: '';
105
+ position: absolute;
106
+ left: 0;
107
+ top: 50%;
108
+ transform: translateY(-50%);
109
+ width: 3px;
110
+ height: 60%;
111
+ border-radius: var(--r-pill);
112
+ background: var(--accent);
113
+ }
77
114
  .nav-label {
78
115
  flex: 1;
79
116
  min-width: 0; /* allow the label to shrink/ellipsis instead of overflowing */
80
117
  overflow: hidden;
81
118
  text-overflow: ellipsis;
82
119
  }
120
+ .nav-trail {
121
+ flex: none;
122
+ }
123
+ /* Dot is the rail-width stand-in for the badge: hidden at full width. */
124
+ .nav-dot {
125
+ display: none;
126
+ }
127
+
83
128
  /* Icon-rail: when the sidebar container is narrow, drop the label + trailing
84
- slot and center the icon. Driven by the sidebar's width, not the viewport. */
129
+ pill and center the icon. Driven by the sidebar's width, not the viewport.
130
+ A badge collapses to a dot pinned to the icon's top-right corner. */
85
131
  @container sidebar (max-width: 8rem) {
86
132
  .nav-item {
87
133
  justify-content: center;
@@ -91,5 +137,18 @@
91
137
  .nav-trail {
92
138
  display: none;
93
139
  }
140
+ .nav-dot {
141
+ display: block;
142
+ position: absolute;
143
+ top: 0.4rem;
144
+ right: 0.9rem;
145
+ width: 6px;
146
+ height: 6px;
147
+ border-radius: 50%;
148
+ background: var(--warn);
149
+ }
150
+ .nav-dot.active {
151
+ background: var(--accent);
152
+ }
94
153
  }
95
154
  </style>
@@ -1,11 +1,17 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { IconName } from '../atoms/Icon.svelte';
3
+ type BadgeTone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
3
4
  type $$ComponentProps = {
4
5
  icon: IconName;
5
6
  label: string;
6
7
  href?: string;
7
8
  active?: boolean;
8
9
  onclick?: (e: MouseEvent) => void;
10
+ /** Optional trailing count pill (e.g. unread / missing items). When the
11
+ * item is `active` it adopts the accent fill; otherwise it uses `badgeTone`. */
12
+ badge?: string | number;
13
+ /** Tone for the trailing badge when the item is not active. */
14
+ badgeTone?: BadgeTone;
9
15
  children?: Snippet;
10
16
  [key: string]: unknown;
11
17
  };
@@ -0,0 +1,71 @@
1
+ <script lang="ts">
2
+ // A grouped block of sidebar nav items: an optional uppercase mono section
3
+ // `label` (eyebrow) plus consistent tight spacing between the NavItems passed
4
+ // as children. Saves consumers from re-implementing the label + gap inline for
5
+ // every group. At icon-rail width (sidebar container ≤ 8rem) the label hides
6
+ // and the group spacing tightens so the rail stays compact; a thin top divider
7
+ // then stands in for the dropped label to keep the groups visually separated.
8
+ import type { Snippet } from 'svelte';
9
+
10
+ let {
11
+ label,
12
+ children,
13
+ ...rest
14
+ }: {
15
+ /** Optional section heading; rendered uppercase + mono. Omit for an
16
+ * unlabeled group (still gets the group spacing + rail divider). */
17
+ label?: string;
18
+ children?: Snippet;
19
+ [key: string]: unknown;
20
+ } = $props();
21
+ </script>
22
+
23
+ <div class="nav-section" {...rest}>
24
+ {#if label}
25
+ <div class="nav-section-label">{label}</div>
26
+ {/if}
27
+ <div class="nav-section-items">
28
+ {@render children?.()}
29
+ </div>
30
+ </div>
31
+
32
+ <style>
33
+ .nav-section {
34
+ display: flex;
35
+ flex-direction: column;
36
+ gap: var(--sp-1);
37
+ }
38
+ /* Groups separate themselves with breathing room; the first one sits flush. */
39
+ .nav-section + :global(.nav-section),
40
+ .nav-section:not(:first-child) {
41
+ margin-top: var(--sp-3);
42
+ }
43
+ .nav-section-label {
44
+ padding: 0 var(--sp-3);
45
+ margin-bottom: var(--sp-1);
46
+ color: var(--text-faint);
47
+ font-family: var(--font-mono);
48
+ font-size: var(--fs-xs);
49
+ font-weight: var(--fw-semibold);
50
+ text-transform: uppercase;
51
+ letter-spacing: 0.06em;
52
+ }
53
+ .nav-section-items {
54
+ display: flex;
55
+ flex-direction: column;
56
+ gap: 2px;
57
+ }
58
+
59
+ /* Icon-rail: drop the textual label and use a hairline divider to keep groups
60
+ distinct when the sidebar collapses to a rail. */
61
+ @container sidebar (max-width: 8rem) {
62
+ .nav-section-label {
63
+ display: none;
64
+ }
65
+ .nav-section:not(:first-child) {
66
+ margin-top: var(--sp-2);
67
+ padding-top: var(--sp-2);
68
+ border-top: 1px solid var(--border);
69
+ }
70
+ }
71
+ </style>
@@ -0,0 +1,11 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ /** Optional section heading; rendered uppercase + mono. Omit for an
4
+ * unlabeled group (still gets the group spacing + rail divider). */
5
+ label?: string;
6
+ children?: Snippet;
7
+ [key: string]: unknown;
8
+ };
9
+ declare const NavSection: import("svelte").Component<$$ComponentProps, {}, "">;
10
+ type NavSection = ReturnType<typeof NavSection>;
11
+ export default NavSection;
package/dist/index.d.ts CHANGED
@@ -21,6 +21,7 @@ export { default as AutoGrid } from './components/layouts/AutoGrid.svelte';
21
21
  export { default as Cluster } from './components/layouts/Cluster.svelte';
22
22
  export { default as Container } from './components/layouts/Container.svelte';
23
23
  export { default as NavItem } from './components/layouts/NavItem.svelte';
24
+ export { default as NavSection } from './components/layouts/NavSection.svelte';
24
25
  export { default as Stack } from './components/layouts/Stack.svelte';
25
26
  export { type AccordionItem, default as Accordion, } from './components/molecules/Accordion.svelte';
26
27
  export { default as CodeBlock } from './components/molecules/CodeBlock.svelte';
package/dist/index.js CHANGED
@@ -28,6 +28,7 @@ export { default as AutoGrid } from './components/layouts/AutoGrid.svelte';
28
28
  export { default as Cluster } from './components/layouts/Cluster.svelte';
29
29
  export { default as Container } from './components/layouts/Container.svelte';
30
30
  export { default as NavItem } from './components/layouts/NavItem.svelte';
31
+ export { default as NavSection } from './components/layouts/NavSection.svelte';
31
32
  export { default as Stack } from './components/layouts/Stack.svelte';
32
33
  export { default as Accordion, } from './components/molecules/Accordion.svelte';
33
34
  export { default as CodeBlock } from './components/molecules/CodeBlock.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Minimal, dependency-free Svelte 5 + pure-CSS UI kit. Token-driven atoms, molecules & layouts with theming out of the box.",
5
5
  "type": "module",
6
6
  "license": "MIT",