noph-ui 0.42.1 → 0.43.1

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.
@@ -143,8 +143,8 @@
143
143
  <style>
144
144
  .children-wrapper {
145
145
  flex: 1;
146
- overflow: hidden;
147
- text-overflow: ellipsis;
146
+ overflow: var(--_button-label-overflow, hidden);
147
+ text-overflow: var(--_button-label-text-overflow, ellipsis);
148
148
  text-wrap: nowrap;
149
149
  }
150
150
  .circular-progress {
@@ -186,6 +186,9 @@
186
186
  box-shadow var(--np-motion-expressive-default-effects);
187
187
  }
188
188
  }
189
+ .round {
190
+ border-radius: var(--_round-radius);
191
+ }
189
192
  .xs {
190
193
  font-size: 0.875rem;
191
194
  height: 2rem;
@@ -193,17 +196,12 @@
193
196
  gap: var(--np-button-gap, 0.25rem);
194
197
  --_icon-size: var(--np-button-icon-size, 1.25rem);
195
198
  --np-circular-progress-size: 1.75rem;
196
- }
197
- .xs.round {
198
- border-radius: 1rem;
199
+ --_round-radius: 1rem;
200
+ --_pressed-radius: 0.5rem;
199
201
  }
200
202
  .xs.square {
201
203
  border-radius: var(--np-button-shape, 0.75rem);
202
204
  }
203
- .xs:active,
204
- .xs.pressed {
205
- border-radius: 0.5rem;
206
- }
207
205
  .s {
208
206
  font-size: 0.875rem;
209
207
  height: 2.5rem;
@@ -211,17 +209,12 @@
211
209
  gap: var(--np-button-gap, 0.5rem);
212
210
  --_icon-size: var(--np-button-icon-size, 1.25rem);
213
211
  --np-circular-progress-size: 2rem;
214
- }
215
- .s.round {
216
- border-radius: 1.25rem;
212
+ --_round-radius: 1.25rem;
213
+ --_pressed-radius: 0.5rem;
217
214
  }
218
215
  .s.square {
219
216
  border-radius: var(--np-button-shape, 0.75rem);
220
217
  }
221
- .s:active,
222
- .s.pressed {
223
- border-radius: 0.5rem;
224
- }
225
218
  .m {
226
219
  font-size: 1rem;
227
220
  height: 3.5rem;
@@ -229,17 +222,12 @@
229
222
  gap: var(--np-button-gap, 0.5rem);
230
223
  --_icon-size: var(--np-button-icon-size, 1.5rem);
231
224
  --np-circular-progress-size: 3rem;
232
- }
233
- .m.round {
234
- border-radius: 1.75rem;
225
+ --_round-radius: 1.75rem;
226
+ --_pressed-radius: 0.75rem;
235
227
  }
236
228
  .m.square {
237
229
  border-radius: var(--np-button-shape, 1rem);
238
230
  }
239
- .m:active,
240
- .m.pressed {
241
- border-radius: 0.75rem;
242
- }
243
231
  .l {
244
232
  font-size: 1.5rem;
245
233
  height: 6rem;
@@ -247,17 +235,12 @@
247
235
  gap: var(--np-button-gap, 0.75rem);
248
236
  --_icon-size: var(--np-button-icon-size, 2rem);
249
237
  --np-circular-progress-size: 5rem;
250
- }
251
- .l.round {
252
- border-radius: 3rem;
238
+ --_round-radius: 3rem;
239
+ --_pressed-radius: 1rem;
253
240
  }
254
241
  .l.square {
255
242
  border-radius: var(--np-button-shape, 1.75rem);
256
243
  }
257
- .l:active,
258
- .l.pressed {
259
- border-radius: 1rem;
260
- }
261
244
  .xl {
262
245
  font-size: 2rem;
263
246
  height: 8.5rem;
@@ -265,16 +248,16 @@
265
248
  gap: var(--np-button-gap, 1rem);
266
249
  --_icon-size: var(--np-button-icon-size, 2.5rem);
267
250
  --np-circular-progress-size: 7rem;
268
- }
269
- .xl.round {
270
- border-radius: 4.25rem;
251
+ --_round-radius: 4.25rem;
252
+ --_pressed-radius: 1rem;
271
253
  }
272
254
  .xl.square {
273
255
  border-radius: var(--np-button-shape, 1.75rem);
274
256
  }
275
- .xl:active,
276
- .xl.pressed {
277
- border-radius: 1rem;
257
+ @media (prefers-reduced-motion: no-preference) {
258
+ .np-button:is(:active, .pressed) {
259
+ border-radius: var(--_pressed-radius);
260
+ }
278
261
  }
279
262
  .toggle {
280
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, resolveItem } 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[])
35
+ .filter((item) => !item.hasAttribute('popover'))
36
+ .map(resolveItem)
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;
@@ -163,22 +163,20 @@
163
163
  width: var(--_icon-size, 1.25rem);
164
164
  }
165
165
 
166
+ .round {
167
+ border-radius: var(--_round-radius);
168
+ }
166
169
  .xs {
167
170
  --_icon-size: var(--np-icon-button-icon-size, 1.25rem);
168
171
  height: 2rem;
169
172
  width: 2rem;
170
173
  --np-circular-progress-size: 1.75rem;
171
- }
172
- .xs.round {
173
- border-radius: 1rem;
174
+ --_round-radius: 1rem;
175
+ --_pressed-radius: 0.5rem;
174
176
  }
175
177
  .xs.square {
176
178
  border-radius: var(--np-icon-button-shape, 0.75rem);
177
179
  }
178
- .xs:active,
179
- .xs.pressed {
180
- border-radius: 0.5rem;
181
- }
182
180
  .xs.narrow {
183
181
  width: 1.75rem;
184
182
  }
@@ -190,9 +188,8 @@
190
188
  height: 2.5rem;
191
189
  width: 2.5rem;
192
190
  --np-circular-progress-size: 2rem;
193
- }
194
- .s.round {
195
- border-radius: 1.25rem;
191
+ --_round-radius: 1.25rem;
192
+ --_pressed-radius: 0.5rem;
196
193
  }
197
194
  .s.square {
198
195
  border-radius: var(--np-icon-button-shape, 0.75rem);
@@ -203,18 +200,13 @@
203
200
  .s.wide {
204
201
  width: 3.25rem;
205
202
  }
206
- .s:active,
207
- .s.pressed {
208
- border-radius: 0.5rem;
209
- }
210
203
  .m {
211
204
  --_icon-size: var(--np-icon-button-icon-size, 1.5rem);
212
205
  height: 3.5rem;
213
206
  width: 3.5rem;
214
207
  --np-circular-progress-size: 3rem;
215
- }
216
- .m.round {
217
- border-radius: 1.75rem;
208
+ --_round-radius: 1.75rem;
209
+ --_pressed-radius: 0.75rem;
218
210
  }
219
211
  .m.square {
220
212
  border-radius: var(--np-icon-button-shape, 1rem);
@@ -225,18 +217,13 @@
225
217
  .m.wide {
226
218
  width: 4.5rem;
227
219
  }
228
- .m:active,
229
- .m.pressed {
230
- border-radius: 0.75rem;
231
- }
232
220
  .l {
233
221
  --_icon-size: var(--np-icon-button-icon-size, 2rem);
234
222
  height: 6rem;
235
223
  width: 6rem;
236
224
  --np-circular-progress-size: 5rem;
237
- }
238
- .l.round {
239
- border-radius: 3rem;
225
+ --_round-radius: 3rem;
226
+ --_pressed-radius: 1rem;
240
227
  }
241
228
  .l.square {
242
229
  border-radius: var(--np-icon-button-shape, 1.75rem);
@@ -247,18 +234,13 @@
247
234
  .l.wide {
248
235
  width: 8rem;
249
236
  }
250
- .l:active,
251
- .l.pressed {
252
- border-radius: 1rem;
253
- }
254
237
  .xl {
255
238
  --_icon-size: var(--np-icon-button-icon-size, 2.5rem);
256
239
  height: 8.5rem;
257
240
  width: 8.5rem;
258
241
  --np-circular-progress-size: 7rem;
259
- }
260
- .xl.round {
261
- border-radius: 4.25rem;
242
+ --_round-radius: 4.25rem;
243
+ --_pressed-radius: 1rem;
262
244
  }
263
245
  .xl.square {
264
246
  border-radius: var(--np-icon-button-shape, 1.75rem);
@@ -269,9 +251,10 @@
269
251
  .xl.wide {
270
252
  width: 11.5rem;
271
253
  }
272
- .xl:active,
273
- .xl.pressed {
274
- border-radius: 1rem;
254
+ @media (prefers-reduced-motion: no-preference) {
255
+ .np-icon-button:is(:active, .pressed) {
256
+ border-radius: var(--_pressed-radius);
257
+ }
275
258
  }
276
259
 
277
260
  .toggle {
@@ -324,16 +307,34 @@
324
307
  background-color: var(--np-filled-icon-button-container-color, var(--np-color-primary));
325
308
  }
326
309
  .filled.toggle {
327
- --np-ripple-hover-color: var(--np-color-on-surface-variant);
328
- --np-ripple-pressed-color: var(--np-color-on-surface-variant);
329
- color: var(--np-color-on-surface-variant);
330
- background-color: var(--np-color-surface-container);
310
+ --np-ripple-hover-color: var(
311
+ --np-filled-icon-button-unselected-icon-color,
312
+ var(--np-color-on-surface-variant)
313
+ );
314
+ --np-ripple-pressed-color: var(
315
+ --np-filled-icon-button-unselected-icon-color,
316
+ var(--np-color-on-surface-variant)
317
+ );
318
+ color: var(--np-filled-icon-button-unselected-icon-color, var(--np-color-on-surface-variant));
319
+ background-color: var(
320
+ --np-filled-icon-button-unselected-container-color,
321
+ var(--np-color-surface-container)
322
+ );
331
323
  }
332
324
  .filled.selected {
333
- --np-ripple-hover-color: var(--np-color-on-primary);
334
- --np-ripple-pressed-color: var(--np-color-on-primary);
335
- color: var(--np-color-on-primary);
336
- background-color: var(--np-color-primary);
325
+ --np-ripple-hover-color: var(
326
+ --np-filled-icon-button-selected-icon-color,
327
+ var(--np-color-on-primary)
328
+ );
329
+ --np-ripple-pressed-color: var(
330
+ --np-filled-icon-button-selected-icon-color,
331
+ var(--np-color-on-primary)
332
+ );
333
+ color: var(--np-filled-icon-button-selected-icon-color, var(--np-color-on-primary));
334
+ background-color: var(
335
+ --np-filled-icon-button-selected-container-color,
336
+ var(--np-color-primary)
337
+ );
337
338
  }
338
339
 
339
340
  .tonal {
@@ -0,0 +1,6 @@
1
+ export declare const resolveItem: (item: HTMLElement) => HTMLElement;
2
+ export declare const expandedWidths: (widths: number[], index: number, expandedRatio: number, compressionLimit: number) => number[];
3
+ export declare const parseMotion: (token: string) => {
4
+ duration: number;
5
+ easing: string;
6
+ };
@@ -0,0 +1,32 @@
1
+ export const resolveItem = (item) => {
2
+ let current = item;
3
+ while (current.ownerDocument.defaultView?.getComputedStyle(current).display === 'contents') {
4
+ const child = Array.from(current.children).find((candidate) => !candidate.hasAttribute('popover'));
5
+ if (!child)
6
+ return current;
7
+ current = child;
8
+ }
9
+ return current;
10
+ };
11
+ export const expandedWidths = (widths, index, expandedRatio, compressionLimit) => {
12
+ const next = [...widths];
13
+ if (widths.length < 2 || index < 0 || index >= widths.length || expandedRatio <= 0)
14
+ return next;
15
+ const neighbours = [index - 1, index + 1].filter((i) => i >= 0 && i < widths.length);
16
+ const target = Math.round(Math.min((expandedRatio * widths[index]) / neighbours.length, compressionLimit));
17
+ for (const neighbour of neighbours) {
18
+ const growth = Math.min(target, widths[neighbour]);
19
+ next[neighbour] -= growth;
20
+ next[index] += growth;
21
+ }
22
+ return next;
23
+ };
24
+ export const parseMotion = (token) => {
25
+ const [time, ...rest] = token.trim().split(/\s+/);
26
+ const value = Number.parseFloat(time);
27
+ const easing = rest.join(' ');
28
+ return {
29
+ duration: Number.isNaN(value) ? 0 : time.endsWith('ms') ? value : value * 1000,
30
+ easing: easing || 'linear',
31
+ };
32
+ };
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "noph-ui",
3
- "version": "0.42.1",
3
+ "version": "0.43.1",
4
4
  "license": "MIT",
5
5
  "homepage": "https://noph.dev",
6
6
  "repository": {