noph-ui 0.51.1 → 0.52.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 (132) hide show
  1. package/dist/animation.d.ts +26 -1
  2. package/dist/animation.js +50 -2
  3. package/dist/app-bar/AppBar.svelte +71 -7
  4. package/dist/app-bar/types.d.ts +6 -0
  5. package/dist/autocomplete/AutoComplete.svelte +6 -31
  6. package/dist/autocomplete/AutoComplete.svelte.d.ts +1 -1
  7. package/dist/badge/Badge.svelte +12 -4
  8. package/dist/badge/description.d.ts +4 -0
  9. package/dist/badge/description.js +4 -0
  10. package/dist/button/Button.svelte +55 -20
  11. package/dist/button/ButtonGroup.svelte +139 -19
  12. package/dist/button/ButtonGroup.svelte.d.ts +1 -1
  13. package/dist/button/IconButton.svelte +49 -18
  14. package/dist/button/SegmentedButton.svelte +44 -4
  15. package/dist/button/SplitButton.svelte +77 -3
  16. package/dist/button/SplitButton.svelte.d.ts +1 -1
  17. package/dist/button/buttonGroup.d.ts +0 -4
  18. package/dist/button/buttonGroup.js +0 -9
  19. package/dist/button/groupContext.d.ts +10 -0
  20. package/dist/button/groupContext.js +4 -0
  21. package/dist/button/types.d.ts +19 -0
  22. package/dist/carousel/carouselMorph.js +1 -1
  23. package/dist/checkbox/Checkbox.svelte +5 -0
  24. package/dist/chip/ChipSet.svelte +28 -2
  25. package/dist/chip/FilterChip.svelte +32 -4
  26. package/dist/chip/InputChip.svelte +127 -26
  27. package/dist/chip/SuggestionChip.svelte +6 -22
  28. package/dist/chip/SuggestionChip.svelte.d.ts +1 -2
  29. package/dist/chip/types.d.ts +8 -1
  30. package/dist/date-picker/Calendar.svelte +4 -2
  31. package/dist/date-picker/DatePickerDialog.svelte +1 -0
  32. package/dist/date-picker/DatePickerDialog.svelte.d.ts +1 -1
  33. package/dist/date-picker/DateRangePicker.svelte +1 -0
  34. package/dist/date-picker/DateRangePicker.svelte.d.ts +1 -1
  35. package/dist/date-picker/DockedDatePicker.svelte +12 -5
  36. package/dist/date-picker/DockedDatePicker.svelte.d.ts +1 -1
  37. package/dist/date-picker/DockedDateTimePicker.svelte +12 -5
  38. package/dist/date-picker/DockedDateTimePicker.svelte.d.ts +1 -1
  39. package/dist/date-picker/YearGrid.svelte +4 -2
  40. package/dist/dialog/Dialog.svelte +171 -33
  41. package/dist/dialog/Dialog.svelte.d.ts +2 -1
  42. package/dist/dialog/types.d.ts +6 -0
  43. package/dist/divider/Divider.svelte +36 -10
  44. package/dist/divider/types.d.ts +1 -0
  45. package/dist/fab/ExtendedFab.svelte +26 -60
  46. package/dist/fab/Fab.svelte +13 -48
  47. package/dist/fab/FabMenu.svelte +102 -44
  48. package/dist/fab/FabMenu.svelte.d.ts +2 -1
  49. package/dist/fab/FabMenuItem.svelte +8 -0
  50. package/dist/fab/FabMenuItem.svelte.d.ts +4 -0
  51. package/dist/fab/index.d.ts +1 -0
  52. package/dist/fab/index.js +1 -0
  53. package/dist/fab/types.d.ts +6 -4
  54. package/dist/icons/MenuIcon.svelte +13 -0
  55. package/dist/icons/MenuIcon.svelte.d.ts +7 -0
  56. package/dist/icons/MenuOpenIcon.svelte +15 -0
  57. package/dist/icons/MenuOpenIcon.svelte.d.ts +7 -0
  58. package/dist/icons/index.d.ts +2 -0
  59. package/dist/icons/index.js +2 -0
  60. package/dist/internal/exit.css +47 -0
  61. package/dist/internal/fieldTypes.d.ts +6 -0
  62. package/dist/internal/focus-ring.css +4 -0
  63. package/dist/internal/interest.css +3 -1
  64. package/dist/keyboard-nav.d.ts +12 -1
  65. package/dist/keyboard-nav.js +104 -14
  66. package/dist/list/Item.svelte +52 -6
  67. package/dist/list/List.svelte +35 -2
  68. package/dist/list/ListItem.svelte +16 -2
  69. package/dist/list/context.d.ts +6 -0
  70. package/dist/list/context.js +4 -0
  71. package/dist/list/types.d.ts +7 -0
  72. package/dist/media.d.ts +1 -0
  73. package/dist/media.js +1 -0
  74. package/dist/menu/Menu.svelte +70 -18
  75. package/dist/menu/Menu.svelte.d.ts +2 -1
  76. package/dist/menu/MenuItem.svelte +9 -3
  77. package/dist/menu/types.d.ts +7 -1
  78. package/dist/navigation-bar/NavigationBar.svelte +9 -3
  79. package/dist/navigation-bar/NavigationBarItem.svelte +29 -13
  80. package/dist/navigation-drawer/NavigationDrawer.svelte +96 -20
  81. package/dist/navigation-drawer/NavigationDrawer.svelte.d.ts +2 -1
  82. package/dist/navigation-drawer/NavigationDrawerItem.svelte +8 -3
  83. package/dist/navigation-drawer/NavigationDrawerSection.svelte +49 -0
  84. package/dist/navigation-drawer/NavigationDrawerSection.svelte.d.ts +4 -0
  85. package/dist/navigation-drawer/index.d.ts +1 -0
  86. package/dist/navigation-drawer/index.js +1 -0
  87. package/dist/navigation-drawer/types.d.ts +12 -0
  88. package/dist/navigation-rail/NavigationRail.svelte +367 -7
  89. package/dist/navigation-rail/NavigationRail.svelte.d.ts +1 -1
  90. package/dist/navigation-rail/NavigationRailItem.svelte +190 -53
  91. package/dist/navigation-rail/NavigationRailSection.svelte +57 -0
  92. package/dist/navigation-rail/NavigationRailSection.svelte.d.ts +4 -0
  93. package/dist/navigation-rail/context.d.ts +6 -0
  94. package/dist/navigation-rail/context.js +5 -0
  95. package/dist/navigation-rail/index.d.ts +1 -0
  96. package/dist/navigation-rail/index.js +1 -0
  97. package/dist/navigation-rail/morph.d.ts +14 -0
  98. package/dist/navigation-rail/morph.js +78 -0
  99. package/dist/navigation-rail/types.d.ts +21 -0
  100. package/dist/progress/CircularProgress.svelte +2 -1
  101. package/dist/progress/LinearProgress.svelte +48 -20
  102. package/dist/progress/types.d.ts +1 -0
  103. package/dist/radio/Radio.svelte +7 -1
  104. package/dist/search/Search.svelte +245 -58
  105. package/dist/search/types.d.ts +4 -0
  106. package/dist/select/NativeSelect.svelte +20 -12
  107. package/dist/select/Option.svelte +1 -2
  108. package/dist/select/Select.svelte +59 -140
  109. package/dist/select/VirtualList.svelte +16 -1
  110. package/dist/select/VirtualList.svelte.d.ts +6 -2
  111. package/dist/sheet/Sheet.svelte +223 -5
  112. package/dist/sheet/Sheet.svelte.d.ts +3 -1
  113. package/dist/sheet/types.d.ts +10 -0
  114. package/dist/slider/Slider.svelte +1 -27
  115. package/dist/snackbar/Snackbar.svelte +3 -0
  116. package/dist/snackbar/Snackbar.svelte.d.ts +2 -1
  117. package/dist/switch/Switch.svelte +51 -11
  118. package/dist/tabs/Tab.svelte +28 -14
  119. package/dist/tabs/Tabs.svelte +28 -4
  120. package/dist/tabs/types.d.ts +3 -0
  121. package/dist/text-field/TextField.svelte +49 -13
  122. package/dist/time-picker/DockedTimePicker.svelte +6 -1
  123. package/dist/time-picker/DockedTimePicker.svelte.d.ts +1 -1
  124. package/dist/time-picker/TimePickerDialog.svelte +1 -0
  125. package/dist/time-picker/TimePickerDialog.svelte.d.ts +1 -1
  126. package/dist/toolbar/Toolbar.svelte +21 -11
  127. package/dist/toolbar/types.d.ts +1 -0
  128. package/dist/tooltip/RichTooltip.svelte.d.ts +1 -1
  129. package/dist/tooltip/Tooltip.svelte +3 -1
  130. package/dist/tooltip/Tooltip.svelte.d.ts +1 -1
  131. package/dist/types.js +34 -34
  132. package/package.json +1 -1
@@ -1,2 +1,27 @@
1
+ import type { Attachment } from 'svelte/attachments';
1
2
  export declare const afterTwoFrames: (fn: () => void) => () => void;
2
- export declare const afterExit: (element: Element | undefined, fn: () => void) => () => void;
3
+ /**
4
+ * Calls `fn` once the element's animations have finished, two frames from now so that transitions
5
+ * just started are counted. `subtree` includes those of its descendants. Endless animations, such
6
+ * as a spinner's, are not waited for.
7
+ */
8
+ export declare const afterExit: (element: Element | undefined, fn: () => void, { subtree }?: {
9
+ subtree?: boolean;
10
+ }) => () => void;
11
+ export declare const parseMotion: (token: string) => {
12
+ duration: number;
13
+ easing: string;
14
+ };
15
+ /** Reads a motion token such as `--np-motion-standard-fast-spatial` off an element's styles. */
16
+ export declare const motionOf: (element: Element, token: string) => {
17
+ duration: number;
18
+ easing: string;
19
+ };
20
+ /**
21
+ * Keeps a dialog or popover on screen while it animates out, in browsers that drop it from the top
22
+ * layer the moment it closes because they cannot transition `overlay`, such as Safari. From the
23
+ * start of closing until its exit animations end it carries `data-np-exiting`, and the rules in
24
+ * `internal/exit.css` keep it shown meanwhile, over the page but not blocking it. `beforetoggle`
25
+ * fires before every way of closing, from Escape and light dismiss to `close()`.
26
+ */
27
+ export declare const exitAnimation: Attachment<HTMLElement>;
package/dist/animation.js CHANGED
@@ -8,7 +8,12 @@ export const afterTwoFrames = (fn) => {
8
8
  cancelAnimationFrame(inner);
9
9
  };
10
10
  };
11
- export const afterExit = (element, fn) => {
11
+ /**
12
+ * Calls `fn` once the element's animations have finished, two frames from now so that transitions
13
+ * just started are counted. `subtree` includes those of its descendants. Endless animations, such
14
+ * as a spinner's, are not waited for.
15
+ */
16
+ export const afterExit = (element, fn, { subtree = false } = {}) => {
12
17
  if (!element) {
13
18
  fn();
14
19
  return () => { };
@@ -19,7 +24,9 @@ export const afterExit = (element, fn) => {
19
24
  fn();
20
25
  };
21
26
  const cancelFrames = afterTwoFrames(() => {
22
- const animations = element.getAnimations();
27
+ const animations = element
28
+ .getAnimations({ subtree })
29
+ .filter((animation) => animation.effect?.getTiming().iterations !== Infinity);
23
30
  if (animations.length === 0) {
24
31
  finish();
25
32
  return;
@@ -31,3 +38,44 @@ export const afterExit = (element, fn) => {
31
38
  cancelFrames();
32
39
  };
33
40
  };
41
+ export const parseMotion = (token) => {
42
+ const [time, ...rest] = token.trim().split(/\s+/);
43
+ const value = Number.parseFloat(time);
44
+ const easing = rest.join(' ');
45
+ return {
46
+ duration: Number.isNaN(value) ? 0 : time.endsWith('ms') ? value : value * 1000,
47
+ easing: easing || 'linear',
48
+ };
49
+ };
50
+ /** Reads a motion token such as `--np-motion-standard-fast-spatial` off an element's styles. */
51
+ export const motionOf = (element, token) => parseMotion(getComputedStyle(element).getPropertyValue(token));
52
+ /**
53
+ * Keeps a dialog or popover on screen while it animates out, in browsers that drop it from the top
54
+ * layer the moment it closes because they cannot transition `overlay`, such as Safari. From the
55
+ * start of closing until its exit animations end it carries `data-np-exiting`, and the rules in
56
+ * `internal/exit.css` keep it shown meanwhile, over the page but not blocking it. `beforetoggle`
57
+ * fires before every way of closing, from Escape and light dismiss to `close()`.
58
+ */
59
+ export const exitAnimation = (element) => {
60
+ if (CSS.supports('overlay: auto'))
61
+ return;
62
+ let cancel = () => { };
63
+ const done = () => {
64
+ cancel();
65
+ delete element.dataset.npExiting;
66
+ };
67
+ const onBeforeToggle = (event) => {
68
+ done();
69
+ if (event.newState !== 'closed')
70
+ return;
71
+ if (matchMedia('(prefers-reduced-motion: reduce)').matches)
72
+ return;
73
+ element.dataset.npExiting = '';
74
+ cancel = afterExit(element, done, { subtree: true });
75
+ };
76
+ element.addEventListener('beforetoggle', onBeforeToggle);
77
+ return () => {
78
+ element.removeEventListener('beforetoggle', onBeforeToggle);
79
+ done();
80
+ };
81
+ };
@@ -4,7 +4,10 @@
4
4
  let {
5
5
  variant = 'small',
6
6
  headline,
7
+ headlineLevel,
7
8
  subtitle,
9
+ alignment = 'start',
10
+ image,
8
11
  leading,
9
12
  search,
10
13
  trailing,
@@ -22,7 +25,15 @@
22
25
 
23
26
  {#snippet titles(inline: boolean)}
24
27
  <div class={['np-app-bar-titles', inline && 'np-app-bar-titles-inline']}>
25
- <div class="np-app-bar-headline">{headline}</div>
28
+ {#if image && !inline}
29
+ <div class="np-app-bar-image">{@render image()}</div>
30
+ {/if}
31
+ <!-- The copy shown while collapsed is hidden from assistive technology, so only the other one
32
+ is the heading. -->
33
+ <svelte:element
34
+ this={headlineLevel && !inline ? `h${headlineLevel}` : 'div'}
35
+ class="np-app-bar-headline">{headline}</svelte:element
36
+ >
26
37
  {#if subtitle}
27
38
  <div class="np-app-bar-subtitle">{subtitle}</div>
28
39
  {/if}
@@ -36,6 +47,7 @@
36
47
  'np-app-bar',
37
48
  `np-app-bar-${variant}`,
38
49
  collapses && 'np-app-bar-collapsible',
50
+ alignment === 'center' && 'np-app-bar-center',
39
51
  scroller === 'nearest' && 'np-app-bar-scroller-nearest',
40
52
  attributes.class,
41
53
  ]}
@@ -48,6 +60,9 @@
48
60
  <div class="np-app-bar-search-field">{@render search?.()}</div>
49
61
  {:else if twoLine}
50
62
  <div class="np-app-bar-inline" aria-hidden="true">{@render titles(true)}</div>
63
+ {:else if image}
64
+ <!-- In a small app bar the image takes the headline's place. -->
65
+ <div class="np-app-bar-titles">{@render image()}</div>
51
66
  {:else}
52
67
  {@render titles(false)}
53
68
  {/if}
@@ -82,8 +97,10 @@
82
97
  --_timeline: scroll(nearest block);
83
98
  }
84
99
 
100
+ /* The bar is the open search's stacking context, so it rises to the search's own level, above
101
+ other bars and a page header. */
85
102
  .np-app-bar:has(:global(.np-search-expanded)) {
86
- z-index: 9;
103
+ z-index: var(--np-search-z-index, 24);
87
104
  }
88
105
 
89
106
  .np-app-bar-row {
@@ -105,6 +122,11 @@
105
122
  .np-app-bar-trailing {
106
123
  margin-inline-start: auto;
107
124
  }
125
+ /* The leading button is on surface, the trailing ones on surface variant. In a search app bar
126
+ they are all on surface variant. */
127
+ .np-app-bar:not(.np-app-bar-search) .np-app-bar-leading {
128
+ --np-icon-button-icon-color: var(--np-color-on-surface);
129
+ }
108
130
 
109
131
  .np-app-bar-titles {
110
132
  display: flex;
@@ -125,6 +147,7 @@
125
147
  }
126
148
 
127
149
  .np-app-bar-headline {
150
+ margin: 0;
128
151
  font-size: var(--_headline-size);
129
152
  line-height: var(--_headline-line-height);
130
153
  font-weight: 400;
@@ -152,12 +175,20 @@
152
175
  --_subtitle-line-height: 1rem;
153
176
  }
154
177
 
178
+ /* The search fills the space between the leading and trailing elements up to 312dp, then
179
+ grows only to half of it, centred. The bar sits 8dp from the elements beside it, so the
180
+ margin a search keeps for its open view stays out of the row. */
155
181
  .np-app-bar-search-field {
156
182
  flex: 1;
157
183
  min-width: 0;
158
184
  display: flex;
159
185
  align-items: center;
160
- padding-inline: 0.25rem;
186
+ justify-content: center;
187
+ container-type: inline-size;
188
+ --np-search-pane-margin: 0;
189
+ }
190
+ .np-app-bar-search-field :global(.np-search:not(.np-search-full-screen.np-search-expanded)) {
191
+ width: min(100%, max(19.5rem, 50cqi));
161
192
  }
162
193
  .np-app-bar-search-field :global(.np-search:not(.np-search-expanded) .np-search-bar) {
163
194
  background-color: var(--np-search-container-color, var(--np-color-surface-container));
@@ -179,7 +210,7 @@
179
210
  --_headline-line-height: 2.75rem;
180
211
  --_subtitle-size: 1rem;
181
212
  --_subtitle-line-height: 1.5rem;
182
- --_second-row-height: 5.5rem;
213
+ --_second-row-height: 3.5rem;
183
214
  }
184
215
 
185
216
  .np-app-bar-second-row {
@@ -190,11 +221,44 @@
190
221
  height: auto;
191
222
  min-height: var(--_second-row-height);
192
223
  padding-inline: 1rem;
193
- padding-block-end: 1rem;
224
+ padding-block-end: 0.75rem;
194
225
  overflow: hidden;
195
226
  }
196
- .np-app-bar-medium .np-app-bar-second-row {
197
- padding-block-end: 0.75rem;
227
+ .np-app-bar-image {
228
+ display: flex;
229
+ margin-block-end: 0.5rem;
230
+ }
231
+
232
+ /* Centred, a small app bar puts its headline in the middle of the bar whatever sits beside it. */
233
+ .np-app-bar-center .np-app-bar-row {
234
+ display: grid;
235
+ grid-template-columns: minmax(max-content, 1fr) auto minmax(max-content, 1fr);
236
+ }
237
+ .np-app-bar-center .np-app-bar-leading {
238
+ grid-column: 1;
239
+ }
240
+ .np-app-bar-center .np-app-bar-row > .np-app-bar-titles,
241
+ .np-app-bar-center .np-app-bar-inline,
242
+ .np-app-bar-center .np-app-bar-search-field {
243
+ grid-column: 2;
244
+ padding-inline: 0;
245
+ }
246
+ .np-app-bar-center.np-app-bar-search .np-app-bar-row {
247
+ grid-template-columns: auto 1fr auto;
248
+ }
249
+ .np-app-bar-center .np-app-bar-trailing {
250
+ grid-column: 3;
251
+ justify-self: end;
252
+ }
253
+ .np-app-bar-center .np-app-bar-titles {
254
+ align-items: center;
255
+ text-align: center;
256
+ }
257
+ .np-app-bar-center .np-app-bar-second-row {
258
+ justify-content: center;
259
+ }
260
+ .np-app-bar-center .np-app-bar-search-field :global(.np-search-input) {
261
+ text-align: center;
198
262
  }
199
263
 
200
264
  .np-app-bar-inline {
@@ -3,7 +3,13 @@ import type { HTMLAttributes } from 'svelte/elements';
3
3
  export interface AppBarProps extends HTMLAttributes<HTMLElement> {
4
4
  variant?: 'search' | 'small' | 'medium' | 'large';
5
5
  headline?: string;
6
+ /** Renders the headline as a heading of this level. Without it the headline is plain text. */
7
+ headlineLevel?: 1 | 2 | 3 | 4 | 5 | 6;
6
8
  subtitle?: string;
9
+ /** Headline and subtitle at the leading edge or centered. */
10
+ alignment?: 'start' | 'center';
11
+ /** An image or logo. It replaces the headline of a small app bar and sits above it otherwise. */
12
+ image?: Snippet;
7
13
  leading?: Snippet;
8
14
  search?: Snippet;
9
15
  trailing?: Snippet;
@@ -38,6 +38,7 @@
38
38
  let useVirtualList = $derived(displayOptions.length > virtualThreshold)
39
39
  let widthProp = $derived(clampMenuWidth || useVirtualList ? 'width' : 'min-width')
40
40
  let menuElement = $state<HTMLDivElement>()
41
+ let virtualList = $state<VirtualList<AutoCompleteOption>>()
41
42
  let finalPopulated = $derived(populated)
42
43
  let activeIndex = $state(NO_INDEX)
43
44
 
@@ -81,25 +82,7 @@
81
82
  optEl.scrollIntoView({ block: 'nearest' })
82
83
  return
83
84
  }
84
- if (useVirtualList && menuElement) {
85
- const viewport = menuElement.querySelector(
86
- 'svelte-virtual-list-viewport',
87
- ) as HTMLElement | null
88
- if (!viewport) return
89
- let rowHeight = 48
90
- const firstRow = viewport.querySelector('[id^="' + uid + '-opt-"]') as HTMLElement | null
91
- if (firstRow) {
92
- rowHeight = firstRow.offsetHeight || rowHeight
93
- }
94
- const top = activeIndex * rowHeight
95
- const bottom = top + rowHeight
96
- const { scrollTop, clientHeight } = viewport
97
- if (top < scrollTop) {
98
- viewport.scrollTop = top
99
- } else if (bottom > scrollTop + clientHeight) {
100
- viewport.scrollTop = bottom - clientHeight
101
- }
102
- }
85
+ if (useVirtualList) virtualList?.scrollToIndex(activeIndex)
103
86
  })
104
87
  </script>
105
88
 
@@ -169,13 +152,9 @@
169
152
  event.preventDefault()
170
153
  return
171
154
  }
172
- if (event.key === 'Home') {
173
- setActive(0)
174
- event.preventDefault()
175
- return
176
- }
177
- if (event.key === 'End') {
178
- setActive(displayOptions.length - 1)
155
+ // Home and End move the caret, unless the arrows have already moved into the options.
156
+ if ((event.key === 'Home' || event.key === 'End') && open && activeIndex >= 0) {
157
+ setActive(event.key === 'Home' ? 0 : displayOptions.length - 1)
179
158
  event.preventDefault()
180
159
  return
181
160
  }
@@ -227,11 +206,7 @@
227
206
  bind:element={menuElement}
228
207
  >
229
208
  {#if useVirtualList}
230
- <VirtualList
231
- height="250px"
232
- itemHeight={displayOptions.some(({ supportingText }) => supportingText) ? 68 : 48}
233
- items={displayOptions}
234
- >
209
+ <VirtualList bind:this={virtualList} height="250px" items={displayOptions}>
235
210
  {#snippet row(option, index)}
236
211
  {@render item(option, index)}
237
212
  {/snippet}
@@ -1,4 +1,4 @@
1
1
  import type { AutoCompleteProps } from './types.js';
2
- declare const AutoComplete: import("svelte").Component<AutoCompleteProps, {}, "element" | "value" | "open">;
2
+ declare const AutoComplete: import("svelte").Component<AutoCompleteProps, {}, "open" | "element" | "value">;
3
3
  type AutoComplete = ReturnType<typeof AutoComplete>;
4
4
  export default AutoComplete;
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { badgeText } from './description.js'
2
3
  import type { BadgeProps } from './types.js'
3
4
 
4
5
  let {
@@ -23,7 +24,7 @@
23
24
  >
24
25
  {#if label !== undefined}
25
26
  <div class="np-badge-label">
26
- {label}
27
+ {badgeText(label)}
27
28
  </div>
28
29
  {/if}
29
30
  </div>
@@ -35,15 +36,21 @@
35
36
  background-color: var(--np-color-error);
36
37
  border-radius: var(--np-shape-corner-full);
37
38
  position: var(--np-badge-position, absolute);
38
- top: var(--np-badge-top, 0);
39
- inset-inline-start: var(--np-badge-start, auto);
40
- inset-inline-end: var(--np-badge-end, auto);
39
+ top: var(--np-badge-top, var(--_top));
40
+ inset-inline-start: var(--np-badge-start, var(--_start));
41
41
  }
42
+ /* Anchored inside the icon at its top trailing corner. A large badge keeps its leading edge and
43
+ grows toward the trailing side. */
42
44
  .np-badge-container-label {
45
+ --_top: -0.125rem;
46
+ --_start: calc(100% - 0.75rem);
47
+ box-sizing: border-box;
43
48
  height: 1rem;
44
49
  min-width: 1rem;
45
50
  }
46
51
  .np-badge-container-no-label {
52
+ --_top: 0;
53
+ --_start: calc(100% - 0.375rem);
47
54
  width: 0.375rem;
48
55
  height: 0.375rem;
49
56
  }
@@ -52,6 +59,7 @@
52
59
  padding-inline: 0.25rem;
53
60
  font-weight: 500;
54
61
  font-size: 0.6875rem;
62
+ white-space: nowrap;
55
63
  display: flex;
56
64
  align-items: center;
57
65
  }
@@ -0,0 +1,4 @@
1
+ /** What a navigation destination reads after its name for its badge: the given label, else the count, else "New notification". */
2
+ export declare const badgeDescription: (label: string | number | undefined, ariaLabel?: string) => string;
3
+ /** Shows counts above 999 as "999+", so a large badge keeps to four characters. */
4
+ export declare const badgeText: (label: string | number) => string;
@@ -0,0 +1,4 @@
1
+ /** What a navigation destination reads after its name for its badge: the given label, else the count, else "New notification". */
2
+ export const badgeDescription = (label, ariaLabel) => ariaLabel ?? (label !== undefined ? String(label) : 'New notification');
3
+ /** Shows counts above 999 as "999+", so a large badge keeps to four characters. */
4
+ export const badgeText = (label) => typeof label === 'number' && label > 999 ? '999+' : String(label);
@@ -5,6 +5,7 @@
5
5
  import Ripple from '../ripple/Ripple.svelte'
6
6
  import Tooltip from '../tooltip/Tooltip.svelte'
7
7
  import type { HTMLButtonAttributes } from 'svelte/elements'
8
+ import { getButtonGroupContext } from './groupContext.js'
8
9
  import type { ButtonProps } from './types.js'
9
10
 
10
11
  let {
@@ -17,8 +18,8 @@
17
18
  disabled = false,
18
19
  loading = false,
19
20
  loadingAriaLabel,
20
- size = 's',
21
- shape = 'round',
21
+ size: sizeProp,
22
+ shape: shapeProp,
22
23
  toggle = false,
23
24
  selected = $bindable(false),
24
25
  ...attributes
@@ -29,6 +30,24 @@
29
30
  let isLink = $derived(attributes.href != null && !disabled && !loading)
30
31
 
31
32
  let tooltipId = $derived(title && !disabled && !loading ? uid : undefined)
33
+ // A visible label names the button, the tooltip only describes it. Without one, the tooltip
34
+ // text is the name, and describing the button with it again would read it twice.
35
+ let tooltipNames = $derived(!!tooltipId && !children && !attributes['aria-label'])
36
+
37
+ // Inside a ButtonGroup, the group sets the size and shape its buttons leave open, and with
38
+ // `selection` it decides what is selected.
39
+ const group = getButtonGroupContext()
40
+ let size = $derived(sizeProp ?? group?.size ?? 's')
41
+ let shape = $derived(shapeProp ?? group?.shape ?? 'round')
42
+ let groupValue = $derived(
43
+ group?.selection && attributes.value != null ? String(attributes.value) : undefined,
44
+ )
45
+ let isToggle = $derived(toggle || groupValue !== undefined)
46
+ let isSelected = $derived(groupValue !== undefined ? group!.isSelected(groupValue) : selected)
47
+ // A selected toggle swaps its resting shape: round turns square, square turns round.
48
+ let shapeClass = $derived(
49
+ isSelected ? (shape === 'square' ? 'round' : 'square') : loading ? 'square' : shape,
50
+ )
32
51
 
33
52
  const morph = pressMorph()
34
53
 
@@ -41,6 +60,7 @@
41
60
  {#snippet content()}
42
61
  {#if !disabled && !loading}
43
62
  <Ripple forElement={element} />
63
+ <span class="np-touch"></span>
44
64
  {/if}
45
65
  {#if loading}
46
66
  <div class="circular-progress">
@@ -68,21 +88,21 @@
68
88
  <a
69
89
  {...attributes}
70
90
  onclick={(event) => {
71
- if (!toggle) {
91
+ if (!isToggle) {
72
92
  handlePress()
73
93
  }
74
94
  attributes.onclick?.(event)
75
95
  }}
76
- aria-describedby={tooltipId ?? attributes['aria-describedby']}
96
+ aria-describedby={(!tooltipNames && tooltipId) || attributes['aria-describedby']}
77
97
  interestfor={tooltipId ?? attributes['interestfor']}
78
- aria-label={title || attributes['aria-label']}
98
+ aria-label={attributes['aria-label'] ?? (tooltipNames ? title : undefined)}
79
99
  bind:this={element}
80
100
  class={[
81
101
  'np-button',
82
102
  size,
83
- selected ? 'square' : shape,
84
- toggle && 'toggle',
85
- selected && 'selected',
103
+ shapeClass,
104
+ isToggle && 'toggle',
105
+ isSelected && 'selected',
86
106
  'enabled',
87
107
  variant,
88
108
  morph.pressed && 'pressed',
@@ -94,16 +114,18 @@
94
114
  {:else}
95
115
  <button
96
116
  {...attributes as HTMLButtonAttributes}
97
- aria-describedby={tooltipId ?? attributes['aria-describedby']}
117
+ aria-describedby={(!tooltipNames && tooltipId) || attributes['aria-describedby']}
98
118
  interestfor={tooltipId ?? attributes['interestfor']}
99
- aria-label={title || attributes['aria-label']}
119
+ aria-label={attributes['aria-label'] ?? (tooltipNames ? title : undefined)}
100
120
  disabled={disabled || loading}
101
- aria-pressed={toggle ? selected : undefined}
121
+ aria-pressed={isToggle ? isSelected : undefined}
102
122
  aria-busy={loading}
103
123
  type={(attributes['type'] as 'button' | 'submit' | 'reset' | 'button') ?? undefined}
104
124
  bind:this={element}
105
125
  onclick={(event) => {
106
- if (toggle) {
126
+ if (groupValue !== undefined) {
127
+ group!.toggle(groupValue)
128
+ } else if (toggle) {
107
129
  selected = !selected
108
130
  } else {
109
131
  handlePress()
@@ -113,9 +135,9 @@
113
135
  class={[
114
136
  'np-button',
115
137
  size,
116
- selected || loading ? 'square' : shape,
117
- toggle && 'toggle',
118
- selected && 'selected',
138
+ shapeClass,
139
+ isToggle && 'toggle',
140
+ isSelected && 'selected',
119
141
  loading && 'np-loading',
120
142
  disabled || loading ? `${variant}-disabled disabled` : `${variant} enabled`,
121
143
  morph.pressed && 'pressed',
@@ -131,10 +153,10 @@
131
153
  {/if}
132
154
 
133
155
  <style>
156
+ /* The label is never truncated or wrapped, the button grows to fit it instead. */
134
157
  .children-wrapper {
135
- flex: 1;
136
- overflow: var(--_button-label-overflow, hidden);
137
- text-overflow: var(--_button-label-text-overflow, ellipsis);
158
+ min-width: 0;
159
+ overflow: var(--_button-label-overflow, visible);
138
160
  text-wrap: nowrap;
139
161
  }
140
162
  .circular-progress {
@@ -162,8 +184,10 @@
162
184
  display: inline-flex;
163
185
  user-select: none;
164
186
  align-items: center;
165
- text-align: start;
166
- overflow: hidden;
187
+ /* Icon and label stay grouped and centered, also in a stretched button. */
188
+ justify-content: center;
189
+ text-align: center;
190
+ min-width: var(--_button-min-width, max-content);
167
191
  font-weight: 500;
168
192
  text-decoration: none;
169
193
  --np-icon-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
@@ -276,6 +300,17 @@
276
300
  outline-width: 3px;
277
301
  outline-offset: 2px;
278
302
  }
303
+ .enabled:focus-visible :global(.np-ripple-surface)::before {
304
+ opacity: var(--np-ripple-focus-opacity, 0.1);
305
+ }
306
+ /* Extra small and small buttons keep a 48px tall target around them. */
307
+ .np-touch {
308
+ position: absolute;
309
+ inset-inline: 0;
310
+ top: 50%;
311
+ translate: 0 -50%;
312
+ height: max(3rem, 100%);
313
+ }
279
314
  @media (prefers-reduced-motion: no-preference) {
280
315
  .enabled:focus-visible {
281
316
  animation: focusAnimation var(--np-motion-expressive-slow-effects) forwards;