noph-ui 0.42.0 → 0.43.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.
@@ -26,6 +26,8 @@
26
26
 
27
27
  const uid = $props.id()
28
28
 
29
+ const isLink = $derived(attributes.href != null && !disabled && !loading)
30
+
29
31
  const tooltipId = $derived(title && !disabled && !loading ? uid : undefined)
30
32
 
31
33
  let pressed = $state(false)
@@ -72,7 +74,7 @@
72
74
  {/if}
73
75
  {/snippet}
74
76
 
75
- {#if 'href' in attributes && !disabled && !loading}
77
+ {#if isLink}
76
78
  <a
77
79
  {...attributes}
78
80
  onclick={(event) => {
@@ -141,8 +143,8 @@
141
143
  <style>
142
144
  .children-wrapper {
143
145
  flex: 1;
144
- overflow: hidden;
145
- text-overflow: ellipsis;
146
+ overflow: var(--_button-label-overflow, hidden);
147
+ text-overflow: var(--_button-label-text-overflow, ellipsis);
146
148
  text-wrap: nowrap;
147
149
  }
148
150
  .circular-progress {
@@ -184,6 +186,9 @@
184
186
  box-shadow var(--np-motion-expressive-default-effects);
185
187
  }
186
188
  }
189
+ .round {
190
+ border-radius: var(--_round-radius);
191
+ }
187
192
  .xs {
188
193
  font-size: 0.875rem;
189
194
  height: 2rem;
@@ -191,17 +196,12 @@
191
196
  gap: var(--np-button-gap, 0.25rem);
192
197
  --_icon-size: var(--np-button-icon-size, 1.25rem);
193
198
  --np-circular-progress-size: 1.75rem;
194
- }
195
- .xs.round {
196
- border-radius: 1rem;
199
+ --_round-radius: 1rem;
200
+ --_pressed-radius: 0.5rem;
197
201
  }
198
202
  .xs.square {
199
203
  border-radius: var(--np-button-shape, 0.75rem);
200
204
  }
201
- .xs:active,
202
- .xs.pressed {
203
- border-radius: 0.5rem;
204
- }
205
205
  .s {
206
206
  font-size: 0.875rem;
207
207
  height: 2.5rem;
@@ -209,17 +209,12 @@
209
209
  gap: var(--np-button-gap, 0.5rem);
210
210
  --_icon-size: var(--np-button-icon-size, 1.25rem);
211
211
  --np-circular-progress-size: 2rem;
212
- }
213
- .s.round {
214
- border-radius: 1.25rem;
212
+ --_round-radius: 1.25rem;
213
+ --_pressed-radius: 0.5rem;
215
214
  }
216
215
  .s.square {
217
216
  border-radius: var(--np-button-shape, 0.75rem);
218
217
  }
219
- .s:active,
220
- .s.pressed {
221
- border-radius: 0.5rem;
222
- }
223
218
  .m {
224
219
  font-size: 1rem;
225
220
  height: 3.5rem;
@@ -227,17 +222,12 @@
227
222
  gap: var(--np-button-gap, 0.5rem);
228
223
  --_icon-size: var(--np-button-icon-size, 1.5rem);
229
224
  --np-circular-progress-size: 3rem;
230
- }
231
- .m.round {
232
- border-radius: 1.75rem;
225
+ --_round-radius: 1.75rem;
226
+ --_pressed-radius: 0.75rem;
233
227
  }
234
228
  .m.square {
235
229
  border-radius: var(--np-button-shape, 1rem);
236
230
  }
237
- .m:active,
238
- .m.pressed {
239
- border-radius: 0.75rem;
240
- }
241
231
  .l {
242
232
  font-size: 1.5rem;
243
233
  height: 6rem;
@@ -245,17 +235,12 @@
245
235
  gap: var(--np-button-gap, 0.75rem);
246
236
  --_icon-size: var(--np-button-icon-size, 2rem);
247
237
  --np-circular-progress-size: 5rem;
248
- }
249
- .l.round {
250
- border-radius: 3rem;
238
+ --_round-radius: 3rem;
239
+ --_pressed-radius: 1rem;
251
240
  }
252
241
  .l.square {
253
242
  border-radius: var(--np-button-shape, 1.75rem);
254
243
  }
255
- .l:active,
256
- .l.pressed {
257
- border-radius: 1rem;
258
- }
259
244
  .xl {
260
245
  font-size: 2rem;
261
246
  height: 8.5rem;
@@ -263,16 +248,16 @@
263
248
  gap: var(--np-button-gap, 1rem);
264
249
  --_icon-size: var(--np-button-icon-size, 2.5rem);
265
250
  --np-circular-progress-size: 7rem;
266
- }
267
- .xl.round {
268
- border-radius: 4.25rem;
251
+ --_round-radius: 4.25rem;
252
+ --_pressed-radius: 1rem;
269
253
  }
270
254
  .xl.square {
271
255
  border-radius: var(--np-button-shape, 1.75rem);
272
256
  }
273
- .xl:active,
274
- .xl.pressed {
275
- border-radius: 1rem;
257
+ @media (prefers-reduced-motion: no-preference) {
258
+ .np-button:is(:active, .pressed) {
259
+ border-radius: var(--_pressed-radius);
260
+ }
276
261
  }
277
262
  .toggle {
278
263
  --np-icon-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
@@ -0,0 +1,224 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte'
3
+ import { MediaQuery } from 'svelte/reactivity'
4
+ import { expandedWidths, parseMotion } from './buttonGroup.js'
5
+ import type { ButtonGroupProps } from './types.js'
6
+
7
+ let {
8
+ variant = 'standard',
9
+ expandedRatio = 0.15,
10
+ compressionLimit = 24,
11
+ element = $bindable(),
12
+ children,
13
+ ...attributes
14
+ }: ButtonGroupProps = $props()
15
+
16
+ const reducedMotion = new MediaQuery('(prefers-reduced-motion: reduce)', false)
17
+
18
+ const settleRatio = 0.75
19
+
20
+ type Timing = { duration: number; easing: string }
21
+
22
+ let animations: Animation[] = []
23
+ let pressed: {
24
+ items: HTMLElement[]
25
+ widths: number[]
26
+ timing: Timing
27
+ growing?: Animation
28
+ } | null = null
29
+ let releaseFrame: number | undefined
30
+ let moving = $state(false)
31
+
32
+ const items = () =>
33
+ element
34
+ ? (Array.from(element.children) as HTMLElement[]).filter(
35
+ (item) => !item.hasAttribute('popover'),
36
+ )
37
+ : []
38
+
39
+ const motion = () =>
40
+ element
41
+ ? parseMotion(
42
+ getComputedStyle(element).getPropertyValue('--np-motion-expressive-fast-spatial'),
43
+ )
44
+ : { duration: 0, easing: 'linear' }
45
+
46
+ const animate = (
47
+ targets: HTMLElement[],
48
+ from: number[],
49
+ to: number[],
50
+ keep: boolean,
51
+ timing: Timing,
52
+ ) =>
53
+ targets.map((item, index) => {
54
+ if (keep) {
55
+ item.style.width = `${to[index]}px`
56
+ } else {
57
+ item.style.removeProperty('width')
58
+ }
59
+ if (from[index] === to[index]) return undefined
60
+ const animation = item.animate(
61
+ [{ width: `${from[index]}px` }, { width: `${to[index]}px` }],
62
+ timing,
63
+ )
64
+ animations.push(animation)
65
+ return animation
66
+ })
67
+
68
+ const stop = () => {
69
+ if (releaseFrame !== undefined) cancelAnimationFrame(releaseFrame)
70
+ releaseFrame = undefined
71
+ for (const animation of animations) animation.cancel()
72
+ animations = []
73
+ moving = false
74
+ if (!pressed) return
75
+ for (const item of pressed.items) item.style.removeProperty('width')
76
+ pressed = null
77
+ }
78
+
79
+ const press = (target: EventTarget | null) => {
80
+ if (variant === 'connected') return
81
+ if (!element || expandedRatio <= 0 || reducedMotion.current) return
82
+ stop()
83
+ const all = items()
84
+ if (all.length < 2) return
85
+ const index = all.findIndex((item) => item.contains(target as Node))
86
+ if (index < 0) return
87
+ const widths = all.map((item) => item.getBoundingClientRect().width)
88
+ const targets = expandedWidths(widths, index, expandedRatio, compressionLimit)
89
+ const timing = motion()
90
+ const growing = animate(all, widths, targets, true, timing)[index]
91
+ pressed = { items: all, widths, timing, growing }
92
+ moving = true
93
+ }
94
+
95
+ const settled = () => {
96
+ const progress = pressed?.growing?.effect?.getComputedTiming().progress
97
+ return progress == null || progress >= settleRatio
98
+ }
99
+
100
+ const release = () => {
101
+ if (!pressed || releaseFrame !== undefined) return
102
+ const back = () => {
103
+ if (!pressed) {
104
+ releaseFrame = undefined
105
+ return
106
+ }
107
+ if (!settled()) {
108
+ releaseFrame = requestAnimationFrame(back)
109
+ return
110
+ }
111
+ const { items: targets, widths, timing } = pressed
112
+ const from = targets.map((item) => item.getBoundingClientRect().width)
113
+ for (const animation of animations) animation.cancel()
114
+ animations = []
115
+ pressed = null
116
+ releaseFrame = undefined
117
+ animate(targets, from, widths, false, timing)
118
+ const returning = animations
119
+ Promise.allSettled(returning.map((animation) => animation.finished)).then(() => {
120
+ if (animations !== returning) return
121
+ animations = []
122
+ moving = false
123
+ })
124
+ }
125
+ releaseFrame = requestAnimationFrame(back)
126
+ }
127
+
128
+ onMount(() => stop)
129
+ </script>
130
+
131
+ <div
132
+ {...attributes}
133
+ role={attributes.role ?? 'group'}
134
+ bind:this={element}
135
+ class={['np-button-group', variant, moving && 'moving', attributes.class]}
136
+ onpointerdown={(event) => {
137
+ press(event.target)
138
+ attributes.onpointerdown?.(event)
139
+ }}
140
+ onpointerup={(event) => {
141
+ release()
142
+ attributes.onpointerup?.(event)
143
+ }}
144
+ onpointercancel={(event) => {
145
+ release()
146
+ attributes.onpointercancel?.(event)
147
+ }}
148
+ onpointerleave={(event) => {
149
+ release()
150
+ attributes.onpointerleave?.(event)
151
+ }}
152
+ onkeydown={(event) => {
153
+ if (!event.repeat && (event.key === ' ' || event.key === 'Enter')) {
154
+ press(event.target)
155
+ }
156
+ attributes.onkeydown?.(event)
157
+ }}
158
+ onkeyup={(event) => {
159
+ release()
160
+ attributes.onkeyup?.(event)
161
+ }}
162
+ onfocusout={(event) => {
163
+ if (!element?.contains(event.relatedTarget as Node)) release()
164
+ attributes.onfocusout?.(event)
165
+ }}
166
+ >
167
+ {@render children?.()}
168
+ </div>
169
+
170
+ <style>
171
+ .np-button-group {
172
+ display: flex;
173
+ align-items: flex-start;
174
+ gap: var(--np-button-group-space, 0.75rem);
175
+ isolation: isolate;
176
+ }
177
+ .connected {
178
+ gap: var(--np-button-group-space, 0.125rem);
179
+ }
180
+
181
+ .np-button-group > :global(:focus-visible) {
182
+ z-index: 1;
183
+ }
184
+ .moving {
185
+ --_button-label-overflow: visible;
186
+ --_button-label-text-overflow: clip;
187
+ }
188
+
189
+ .connected > :global(:not([popover]):has(~ :not([popover]))) {
190
+ border-start-end-radius: var(--np-button-group-inner-corner, var(--np-shape-corner-small));
191
+ border-end-end-radius: var(--np-button-group-inner-corner, var(--np-shape-corner-small));
192
+ }
193
+ .connected > :global(:not([popover]) ~ :not([popover])) {
194
+ border-start-start-radius: var(--np-button-group-inner-corner, var(--np-shape-corner-small));
195
+ border-end-start-radius: var(--np-button-group-inner-corner, var(--np-shape-corner-small));
196
+ }
197
+
198
+ @media (prefers-reduced-motion: no-preference) {
199
+ .connected > :global(:not([popover]):has(~ :not([popover])):is(:active, .pressed)) {
200
+ border-start-end-radius: var(
201
+ --np-button-group-pressed-inner-corner,
202
+ var(--np-shape-corner-extra-small)
203
+ );
204
+ border-end-end-radius: var(
205
+ --np-button-group-pressed-inner-corner,
206
+ var(--np-shape-corner-extra-small)
207
+ );
208
+ }
209
+ .connected > :global(:not([popover]) ~ :not([popover]):is(:active, .pressed)) {
210
+ border-start-start-radius: var(
211
+ --np-button-group-pressed-inner-corner,
212
+ var(--np-shape-corner-extra-small)
213
+ );
214
+ border-end-start-radius: var(
215
+ --np-button-group-pressed-inner-corner,
216
+ var(--np-shape-corner-extra-small)
217
+ );
218
+ }
219
+ }
220
+
221
+ .connected > :global(:not([popover]).selected) {
222
+ border-radius: var(--_round-radius, var(--np-shape-corner-full));
223
+ }
224
+ </style>
@@ -0,0 +1,4 @@
1
+ import type { ButtonGroupProps } from './types.ts';
2
+ declare const ButtonGroup: import("svelte").Component<ButtonGroupProps, {}, "element">;
3
+ type ButtonGroup = ReturnType<typeof ButtonGroup>;
4
+ export default ButtonGroup;
@@ -25,6 +25,8 @@
25
25
  }: IconButtonProps = $props()
26
26
 
27
27
  const uid = $props.id()
28
+
29
+ const isLink = $derived(attributes.href != null && !disabled && !loading)
28
30
  const tooltipId = $derived(title && !disabled && !loading ? uid : undefined)
29
31
 
30
32
  let pressed = $state(false)
@@ -62,7 +64,7 @@
62
64
  {/if}
63
65
  {/snippet}
64
66
 
65
- {#if 'href' in attributes && !disabled && !loading}
67
+ {#if isLink}
66
68
  <a
67
69
  {...attributes}
68
70
  onclick={(event) => {
@@ -161,22 +163,20 @@
161
163
  width: var(--_icon-size, 1.25rem);
162
164
  }
163
165
 
166
+ .round {
167
+ border-radius: var(--_round-radius);
168
+ }
164
169
  .xs {
165
170
  --_icon-size: var(--np-icon-button-icon-size, 1.25rem);
166
171
  height: 2rem;
167
172
  width: 2rem;
168
173
  --np-circular-progress-size: 1.75rem;
169
- }
170
- .xs.round {
171
- border-radius: 1rem;
174
+ --_round-radius: 1rem;
175
+ --_pressed-radius: 0.5rem;
172
176
  }
173
177
  .xs.square {
174
178
  border-radius: var(--np-icon-button-shape, 0.75rem);
175
179
  }
176
- .xs:active,
177
- .xs.pressed {
178
- border-radius: 0.5rem;
179
- }
180
180
  .xs.narrow {
181
181
  width: 1.75rem;
182
182
  }
@@ -188,9 +188,8 @@
188
188
  height: 2.5rem;
189
189
  width: 2.5rem;
190
190
  --np-circular-progress-size: 2rem;
191
- }
192
- .s.round {
193
- border-radius: 1.25rem;
191
+ --_round-radius: 1.25rem;
192
+ --_pressed-radius: 0.5rem;
194
193
  }
195
194
  .s.square {
196
195
  border-radius: var(--np-icon-button-shape, 0.75rem);
@@ -201,18 +200,13 @@
201
200
  .s.wide {
202
201
  width: 3.25rem;
203
202
  }
204
- .s:active,
205
- .s.pressed {
206
- border-radius: 0.5rem;
207
- }
208
203
  .m {
209
204
  --_icon-size: var(--np-icon-button-icon-size, 1.5rem);
210
205
  height: 3.5rem;
211
206
  width: 3.5rem;
212
207
  --np-circular-progress-size: 3rem;
213
- }
214
- .m.round {
215
- border-radius: 1.75rem;
208
+ --_round-radius: 1.75rem;
209
+ --_pressed-radius: 0.75rem;
216
210
  }
217
211
  .m.square {
218
212
  border-radius: var(--np-icon-button-shape, 1rem);
@@ -223,18 +217,13 @@
223
217
  .m.wide {
224
218
  width: 4.5rem;
225
219
  }
226
- .m:active,
227
- .m.pressed {
228
- border-radius: 0.75rem;
229
- }
230
220
  .l {
231
221
  --_icon-size: var(--np-icon-button-icon-size, 2rem);
232
222
  height: 6rem;
233
223
  width: 6rem;
234
224
  --np-circular-progress-size: 5rem;
235
- }
236
- .l.round {
237
- border-radius: 3rem;
225
+ --_round-radius: 3rem;
226
+ --_pressed-radius: 1rem;
238
227
  }
239
228
  .l.square {
240
229
  border-radius: var(--np-icon-button-shape, 1.75rem);
@@ -245,18 +234,13 @@
245
234
  .l.wide {
246
235
  width: 8rem;
247
236
  }
248
- .l:active,
249
- .l.pressed {
250
- border-radius: 1rem;
251
- }
252
237
  .xl {
253
238
  --_icon-size: var(--np-icon-button-icon-size, 2.5rem);
254
239
  height: 8.5rem;
255
240
  width: 8.5rem;
256
241
  --np-circular-progress-size: 7rem;
257
- }
258
- .xl.round {
259
- border-radius: 4.25rem;
242
+ --_round-radius: 4.25rem;
243
+ --_pressed-radius: 1rem;
260
244
  }
261
245
  .xl.square {
262
246
  border-radius: var(--np-icon-button-shape, 1.75rem);
@@ -267,9 +251,10 @@
267
251
  .xl.wide {
268
252
  width: 11.5rem;
269
253
  }
270
- .xl:active,
271
- .xl.pressed {
272
- border-radius: 1rem;
254
+ @media (prefers-reduced-motion: no-preference) {
255
+ .np-icon-button:is(:active, .pressed) {
256
+ border-radius: var(--_pressed-radius);
257
+ }
273
258
  }
274
259
 
275
260
  .toggle {
@@ -0,0 +1,5 @@
1
+ export declare const expandedWidths: (widths: number[], index: number, expandedRatio: number, compressionLimit: number) => number[];
2
+ export declare const parseMotion: (token: string) => {
3
+ duration: number;
4
+ easing: string;
5
+ };
@@ -0,0 +1,22 @@
1
+ export const expandedWidths = (widths, index, expandedRatio, compressionLimit) => {
2
+ const next = [...widths];
3
+ if (widths.length < 2 || index < 0 || index >= widths.length || expandedRatio <= 0)
4
+ return next;
5
+ const neighbours = [index - 1, index + 1].filter((i) => i >= 0 && i < widths.length);
6
+ const target = Math.round(Math.min((expandedRatio * widths[index]) / neighbours.length, compressionLimit));
7
+ for (const neighbour of neighbours) {
8
+ const growth = Math.min(target, widths[neighbour]);
9
+ next[neighbour] -= growth;
10
+ next[index] += growth;
11
+ }
12
+ return next;
13
+ };
14
+ export const parseMotion = (token) => {
15
+ const [time, ...rest] = token.trim().split(/\s+/);
16
+ const value = Number.parseFloat(time);
17
+ const easing = rest.join(' ');
18
+ return {
19
+ duration: Number.isNaN(value) ? 0 : time.endsWith('ms') ? value : value * 1000,
20
+ easing: easing || 'linear',
21
+ };
22
+ };
@@ -1,3 +1,4 @@
1
1
  export { default as Button } from './Button.svelte';
2
+ export { default as ButtonGroup } from './ButtonGroup.svelte';
2
3
  export { default as IconButton } from './IconButton.svelte';
3
4
  export { default as SegmentedButton } from './SegmentedButton.svelte';
@@ -1,3 +1,4 @@
1
1
  export { default as Button } from './Button.svelte';
2
+ export { default as ButtonGroup } from './ButtonGroup.svelte';
2
3
  export { default as IconButton } from './IconButton.svelte';
3
4
  export { default as SegmentedButton } from './SegmentedButton.svelte';
@@ -22,6 +22,13 @@ export interface IconButtonProps extends BaseButtonProps {
22
22
  selectedIcon?: Snippet;
23
23
  width?: 'narrow' | 'wide' | 'default';
24
24
  }
25
+ export interface ButtonGroupProps extends HTMLAttributes<HTMLDivElement> {
26
+ variant?: 'standard' | 'connected';
27
+ expandedRatio?: number;
28
+ compressionLimit?: number;
29
+ element?: HTMLElement;
30
+ children?: Snippet;
31
+ }
25
32
  export interface SegmentedButtonProps extends HTMLAttributes<HTMLDivElement> {
26
33
  name: string;
27
34
  multiSelect?: boolean;
@@ -3,14 +3,12 @@
3
3
  import type { MenuItemProps } from './types.js'
4
4
 
5
5
  let { ...attributes }: MenuItemProps = $props()
6
+
7
+ const isLink = $derived(attributes.href != null)
6
8
  </script>
7
9
 
8
10
  <div class="np-menu-item" role="none">
9
- {#if 'href' in attributes}
10
- <Item role="menuitem" {...attributes} variant="link" />
11
- {:else}
12
- <Item role="menuitem" {...attributes} variant="button" />
13
- {/if}
11
+ <Item role="menuitem" {...attributes} variant={isLink ? 'link' : 'button'} />
14
12
  </div>
15
13
 
16
14
  <style>
@@ -11,6 +11,8 @@
11
11
  icon,
12
12
  ...attributes
13
13
  }: NavigationDrawerItemProps = $props()
14
+
15
+ const isLink = $derived(attributes.href != null)
14
16
  </script>
15
17
 
16
18
  {#snippet content()}
@@ -26,7 +28,7 @@
26
28
  {/if}
27
29
  {/snippet}
28
30
 
29
- {#if 'href' in attributes}
31
+ {#if isLink}
30
32
  <a
31
33
  {...attributes}
32
34
  href={attributes.href}
@@ -6,6 +6,8 @@
6
6
 
7
7
  let { selected, icon, label, ...attributes }: NavigationRailItemProps = $props()
8
8
  let touchEl: HTMLSpanElement | undefined = $state()
9
+
10
+ const isLink = $derived(attributes.href != null)
9
11
  </script>
10
12
 
11
13
  {#snippet content()}
@@ -17,7 +19,7 @@
17
19
  <span class="np-touch" bind:this={touchEl}></span>
18
20
  {/snippet}
19
21
 
20
- {#if 'href' in attributes}
22
+ {#if isLink}
21
23
  <a
22
24
  {...attributes}
23
25
  href={attributes.href}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "noph-ui",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "license": "MIT",
5
5
  "homepage": "https://noph.dev",
6
6
  "repository": {