@nexxtmove/ui 1.40.0 → 1.41.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,34 +1,55 @@
1
1
  <script lang="ts">
2
+ import { EDITOR_FONT_SIZE_STEPS } from '../EditorFontSize/EditorFontSize.vue'
3
+ import type { EditorFontSizeValue } from '../EditorFontSize/EditorFontSize.vue'
4
+
2
5
  /**
3
- * The steps the slider can land on, as the class they are stored as and the
4
- * pixels that class is. The `leading-*` classes are Tailwind's spacing scale
5
- * (calc of `--spacing`, 4px a step), so `leading-6` really is 24px -- and a
6
- * line height set here does not scale with the font size; that is the deal the
7
- * fixed steps make, and nothing here tries to compensate for it.
6
+ * The steps the slider can land on, as the class they are stored as and the factor of the font
7
+ * size that class is. Every class is a unitless arbitrary value (`leading-[1.5]`), so the line
8
+ * height scales with the text: at 128px a fixed `leading-9` (36px) would make the lines overlap.
8
9
  *
9
- * The first step is no class at all: the element keeps its own line height.
10
- * It has no px of its own, so the readout names it instead of measuring it.
10
+ * The first step is no class at all: the element keeps the line height of its `text-*` token,
11
+ * see `EDITOR_TEXT_LINE_HEIGHT_RATIOS`. Its `factor` is `null` because it depends on that token.
11
12
  *
12
- * The consumer stores these classes and lists them in an `@source inline` rule,
13
- * so adding or renaming a token is a breaking change even though the component
14
- * API stays the same.
13
+ * The consumer stores these classes and lists them in an `@source inline` rule (and the back end
14
+ * allows exactly these), so adding or renaming a token is a breaking change even though the
15
+ * component API stays the same.
15
16
  */
16
17
  export const EDITOR_LINE_HEIGHT_STEPS = [
17
- { token: null, px: null },
18
- { token: 'leading-3', px: 12 },
19
- { token: 'leading-4', px: 16 },
20
- { token: 'leading-5', px: 20 },
21
- { token: 'leading-6', px: 24 },
22
- { token: 'leading-7', px: 28 },
23
- { token: 'leading-8', px: 32 },
24
- { token: 'leading-9', px: 36 },
25
- { token: 'leading-10', px: 40 },
26
- { token: 'leading-11', px: 44 },
27
- { token: 'leading-12', px: 48 },
28
- { token: 'leading-14', px: 56 },
29
- { token: 'leading-16', px: 64 },
18
+ { token: null, factor: null },
19
+ { token: 'leading-[1]', factor: 1 },
20
+ { token: 'leading-[1.1]', factor: 1.1 },
21
+ { token: 'leading-[1.2]', factor: 1.2 },
22
+ { token: 'leading-[1.3]', factor: 1.3 },
23
+ { token: 'leading-[1.4]', factor: 1.4 },
24
+ { token: 'leading-[1.5]', factor: 1.5 },
25
+ { token: 'leading-[1.6]', factor: 1.6 },
26
+ { token: 'leading-[1.75]', factor: 1.75 },
27
+ { token: 'leading-[2]', factor: 2 },
30
28
  ] as const
31
29
 
30
+ /**
31
+ * The line height each `text-*` token carries by itself, as a factor of its size: Tailwind v4's
32
+ * default `--text-*--line-height`, written as the same fractions. The theme of this library sets
33
+ * the sizes but not these, so the defaults are what renders. This is what the first step of
34
+ * `EDITOR_LINE_HEIGHT_STEPS`, no class, looks like; exported so a consumer that needs the
35
+ * default line height in px does not keep a copy of its own.
36
+ */
37
+ export const EDITOR_TEXT_LINE_HEIGHT_RATIOS: Readonly<Record<EditorFontSizeValue, number>> = {
38
+ 'text-xs': 1 / 0.75,
39
+ 'text-sm': 1.25 / 0.875,
40
+ 'text-base': 1.5,
41
+ 'text-lg': 1.75 / 1.125,
42
+ 'text-xl': 1.75 / 1.25,
43
+ 'text-2xl': 2 / 1.5,
44
+ 'text-3xl': 2.25 / 1.875,
45
+ 'text-4xl': 2.5 / 2.25,
46
+ 'text-5xl': 1,
47
+ 'text-6xl': 1,
48
+ 'text-7xl': 1,
49
+ 'text-8xl': 1,
50
+ 'text-9xl': 1,
51
+ }
52
+
32
53
  /**
33
54
  * The line height as the editor needs it: the Tailwind class itself, so the
34
55
  * consumer can put the model value straight on the element it styles. The
@@ -41,13 +62,20 @@ export interface EditorLineHeightMessages {
41
62
  /** Unit shown behind the value. Defaults to `'px'`. */
42
63
  unit?: string
43
64
  /**
44
- * Readout of the first step, no class, which has no px size. Defaults to
65
+ * Name of the first step, no class, shown before its px as `Standaard · 20 px`. Defaults to
45
66
  * the Dutch `'Standaard'`.
46
67
  */
47
68
  default?: string
48
69
  }
49
70
 
50
71
  export interface NexxtEditorLineHeightProps {
72
+ /**
73
+ * The `text-*` token the text renders at on the screen on show (for a heading, the token of
74
+ * its heading type): the base the readout turns the factor into px with. Defaults to
75
+ * `text-sm`, the app's text base. A token that is not in `EDITOR_FONT_SIZE_STEPS` falls back
76
+ * to `text-sm`, and a development build warns once per value.
77
+ */
78
+ fontSize?: EditorFontSizeValue
51
79
  /**
52
80
  * Header text of the settings block. Defaults to the Dutch
53
81
  * `'Regelafstand'`; the library has no i18n layer, so a consumer in another
@@ -61,6 +89,14 @@ export interface NexxtEditorLineHeightProps {
61
89
  messages?: EditorLineHeightMessages
62
90
  }
63
91
 
92
+ const DEFAULT_FONT_SIZE = 'text-sm' satisfies EditorFontSizeValue
93
+
94
+ /**
95
+ * Rounds to whole px, half up. The product is snapped to 6 decimals first, so a float a hair under
96
+ * a half (or over a whole px) rounds as the decimal it stands for.
97
+ */
98
+ const roundPx = (value: number) => Math.round(Number(value.toFixed(6)))
99
+
64
100
  const DEFAULT_MESSAGES = {
65
101
  unit: 'px',
66
102
  default: 'Standaard',
@@ -68,13 +104,17 @@ const DEFAULT_MESSAGES = {
68
104
  </script>
69
105
 
70
106
  <script lang="ts" setup>
71
- import { computed } from 'vue'
107
+ import { computed, watch } from 'vue'
72
108
  import NexxtEditorSettingsBlock from '../../atoms/EditorSettingsBlock/EditorSettingsBlock.vue'
73
109
  import NexxtSlider from '../../../atoms/Slider/Slider.vue'
74
110
 
75
111
  defineOptions({ name: 'NexxtEditorLineHeight' })
76
112
 
77
- const { label = 'Regelafstand', messages } = defineProps<NexxtEditorLineHeightProps>()
113
+ const {
114
+ fontSize = DEFAULT_FONT_SIZE,
115
+ label = 'Regelafstand',
116
+ messages,
117
+ } = defineProps<NexxtEditorLineHeightProps>()
78
118
 
79
119
  const emit = defineEmits<{
80
120
  /**
@@ -87,6 +127,10 @@ const emit = defineEmits<{
87
127
  /**
88
128
  * The selected line height, as the Tailwind class to apply. `null` -- also
89
129
  * what an unset model is -- is the default step: no class at all.
130
+ *
131
+ * A value that is not on the list (an old px class such as `leading-6`, say) is shown on the
132
+ * default step and makes a development build warn once per value; the first change writes a
133
+ * valid class back.
90
134
  */
91
135
  const model = defineModel<EditorLineHeightValue | null>({ default: null })
92
136
 
@@ -94,20 +138,55 @@ const copy = computed(() => ({ ...DEFAULT_MESSAGES, ...messages }))
94
138
 
95
139
  const maxIndex = EDITOR_LINE_HEIGHT_STEPS.length - 1
96
140
 
97
- /**
98
- * Position of the current value on the step list. A value that is not on the
99
- * list -- a stored `leading-13`, say -- falls back to the first step, the
100
- * default, instead of leaving the thumb off the track; the first change writes
101
- * a valid class back.
102
- */
103
- const stepIndex = computed(() =>
104
- Math.max(
105
- 0,
106
- EDITOR_LINE_HEIGHT_STEPS.findIndex(({ token }) => token === model.value),
107
- ),
108
- )
109
-
110
- // The slider moves over the index, not over the px value, so its model is
141
+ const indexOf = (token: string | null | undefined) =>
142
+ EDITOR_LINE_HEIGHT_STEPS.findIndex((step) => step.token === token)
143
+
144
+ const fontSizeStep = (token: string) => EDITOR_FONT_SIZE_STEPS.find((step) => step.token === token)
145
+
146
+ /** The size the readout is based on; an unknown `fontSize` falls back to `text-sm`. */
147
+ const baseSize = computed(() => fontSizeStep(fontSize) ?? fontSizeStep(DEFAULT_FONT_SIZE)!)
148
+
149
+ /** Position of the current value on the step list; off the list is the default step. */
150
+ const stepIndex = computed(() => Math.max(0, indexOf(model.value)))
151
+
152
+ // The same guard as NexxtEditorFontSize: `process.env.NODE_ENV` stays in the library build, so
153
+ // the consumer's bundler decides. Once per value per instance.
154
+ if (process.env.NODE_ENV !== 'production') {
155
+ const warned = new Set<string>()
156
+ const warnOnce = (key: string, message: string) => {
157
+ if (warned.has(key)) return
158
+ warned.add(key)
159
+ console.warn(`[NexxtEditorLineHeight] ${message}`)
160
+ }
161
+
162
+ watch(
163
+ () => fontSize,
164
+ (token) => {
165
+ if (!fontSizeStep(token)) {
166
+ warnOnce(
167
+ `fontSize:${token}`,
168
+ `fontSize "${token}" is not in EDITOR_FONT_SIZE_STEPS; falling back to ${DEFAULT_FONT_SIZE}.`,
169
+ )
170
+ }
171
+ },
172
+ { immediate: true },
173
+ )
174
+
175
+ watch(
176
+ model,
177
+ (value) => {
178
+ if (value !== null && indexOf(value) === -1) {
179
+ warnOnce(
180
+ `model:${value}`,
181
+ `"${value}" is not in EDITOR_LINE_HEIGHT_STEPS; showing the default step until a step is picked.`,
182
+ )
183
+ }
184
+ },
185
+ { immediate: true },
186
+ )
187
+ }
188
+
189
+ // The slider moves over the index, not over the factor, so its model is
111
190
  // translated both ways here.
112
191
  const sliderValue = computed({
113
192
  get: () => [stepIndex.value],
@@ -116,10 +195,16 @@ const sliderValue = computed({
116
195
  },
117
196
  })
118
197
 
198
+ /**
199
+ * The line height in whole px at the current size. The default step shows the token's own line
200
+ * height, named: `Standaard · 20 px`.
201
+ */
119
202
  const displayValue = computed(() => {
120
- const { px } = EDITOR_LINE_HEIGHT_STEPS[stepIndex.value]
203
+ const { factor } = EDITOR_LINE_HEIGHT_STEPS[stepIndex.value]
204
+ const { token, px: size } = baseSize.value
205
+ const px = `${roundPx((factor ?? EDITOR_TEXT_LINE_HEIGHT_RATIOS[token]) * size)} ${copy.value.unit}`
121
206
 
122
- return px === null ? copy.value.default : `${px} ${copy.value.unit}`
207
+ return factor === null ? `${copy.value.default} · ${px}` : px
123
208
  })
124
209
  </script>
125
210
 
@@ -146,13 +231,14 @@ const displayValue = computed(() => {
146
231
  />
147
232
 
148
233
  <!--
149
- `min-w-16` so `Standaard` fits and the slider keeps its size moving off
150
- the default step; `tabular-nums` stops the digits from jittering.
151
- gray-700, not the gray-500 Slider uses for its own readout: axe puts
152
- gray-500 on white at 1.84:1, well under the 4.5:1 it needs at this size.
234
+ `min-w-36` (144px) fits the widest readout, `Standaard · 128 px` (127px in Chromium),
235
+ so the slider keeps its size moving between steps; the ReadoutWidth story measures it.
236
+ `tabular-nums` stops the digits from jittering. gray-700, not the gray-500 Slider uses
237
+ for its own readout: axe puts gray-500 on white at 1.84:1, well under the 4.5:1 it needs
238
+ at this size.
153
239
  -->
154
240
  <span
155
- class="min-w-16 shrink-0 text-right small-normal whitespace-nowrap text-gray-700 tabular-nums"
241
+ class="min-w-36 shrink-0 text-right small-normal whitespace-nowrap text-gray-700 tabular-nums"
156
242
  >
157
243
  {{ displayValue }}
158
244
  </span>
@@ -1,5 +1,14 @@
1
1
  <script setup lang="ts">
2
- import { computed, nextTick, onBeforeUnmount, ref, useId, useTemplateRef, watch } from 'vue'
2
+ import {
3
+ computed,
4
+ nextTick,
5
+ onBeforeUnmount,
6
+ ref,
7
+ useId,
8
+ useSlots,
9
+ useTemplateRef,
10
+ watch,
11
+ } from 'vue'
3
12
  import { usePreferredReducedMotion } from '@vueuse/core'
4
13
  import NexxtButton from '../../atoms/Button/Button.vue'
5
14
  import NexxtIcon, { type NexxtIconName } from '../../atoms/Icon/Icon.vue'
@@ -11,8 +20,8 @@ export interface NexxtPickerItemData {
11
20
  id: string
12
21
  /** Visible label and accessible name of the option; searched and matched by type-ahead. */
13
22
  label: string
14
- /** Icon in front of the label, also passed to the `preview` slot. */
15
- icon: NexxtIconName
23
+ /** Icon in front of the label, also passed to the `preview` slot. Without it the row has no icon. */
24
+ icon?: NexxtIconName
16
25
  /** Id of a group in `groups`. Missing, or not in `groups`: ungrouped, rendered at the top. */
17
26
  group?: string
18
27
  /** Extra search terms (synonyms, old names). Matched, never shown. */
@@ -57,6 +66,12 @@ interface NexxtPickerListProps {
57
66
  * `(n) => \`${n} resultaten\``. Without it the region stays empty.
58
67
  */
59
68
  resultsAnnouncement?: (count: number) => string
69
+ /**
70
+ * More items are on their way. Sets `aria-busy` on the listbox and holds back the empty state,
71
+ * so a search that finds nothing yet does not claim there is nothing. Pair it with placeholder
72
+ * rows in the `append` slot.
73
+ */
74
+ busy?: boolean
60
75
  }
61
76
 
62
77
  interface Section {
@@ -74,6 +89,7 @@ const props = withDefaults(defineProps<NexxtPickerListProps>(), {
74
89
  groups: () => [],
75
90
  search: '',
76
91
  collapsible: true,
92
+ busy: false,
77
93
  })
78
94
 
79
95
  /**
@@ -101,8 +117,15 @@ defineSlots<{
101
117
  item?(props: { item: NexxtPickerItemData; highlighted: boolean }): unknown
102
118
  /** Replaces the content of the empty state. */
103
119
  empty?(props: { search: string; clear: () => void }): unknown
120
+ /**
121
+ * Inside the listbox, after the last group: placeholder rows while `busy`, say. Not an option and
122
+ * never reached by the keyboard, so mark it `aria-hidden`.
123
+ */
124
+ append?(): unknown
104
125
  }>()
105
126
 
127
+ const slots = useSlots()
128
+
106
129
  const PAGE_SIZE = 8
107
130
  const TYPEAHEAD_RESET_MS = 800
108
131
  const ANNOUNCE_DELAY_MS = 500
@@ -168,7 +191,7 @@ const visibleItems = computed(() => [
168
191
 
169
192
  /** Anything rendered inside the listbox, closed group headings included. */
170
193
  const hasContent = computed(() => ungrouped.value.length > 0 || sections.value.length > 0)
171
- const showEmpty = computed(() => searching.value && visibleItems.value.length === 0)
194
+ const showEmpty = computed(() => searching.value && visibleItems.value.length === 0 && !props.busy)
172
195
 
173
196
  // ─── Highlight ────────────────────────────────────────────────────────────────
174
197
 
@@ -509,17 +532,31 @@ const clear = () => emit('clear')
509
532
  const announcement = ref('')
510
533
  let announceTimer: ReturnType<typeof setTimeout> | undefined
511
534
 
512
- // Only after the search changed, debounced, so intermediate states while typing stay silent
535
+ /** A search change that has not been announced yet. */
536
+ let announcePending = false
537
+
538
+ // Only after the search changed, debounced, so intermediate states while typing stay silent. While
539
+ // `busy` the count is not final, so the announcement waits and follows once `busy` ends; a list
540
+ // that is never busy announces exactly as before.
541
+ const scheduleAnnouncement = () => {
542
+ clearTimeout(announceTimer)
543
+ if (!announcePending || props.busy) return
544
+ announceTimer = setTimeout(() => {
545
+ announcePending = false
546
+ announcement.value = props.resultsAnnouncement?.(visibleItems.value.length) ?? ''
547
+ }, ANNOUNCE_DELAY_MS)
548
+ }
549
+
513
550
  watch(
514
551
  () => props.search,
515
552
  () => {
516
- clearTimeout(announceTimer)
517
- announceTimer = setTimeout(() => {
518
- announcement.value = props.resultsAnnouncement?.(visibleItems.value.length) ?? ''
519
- }, ANNOUNCE_DELAY_MS)
553
+ announcePending = true
554
+ scheduleAnnouncement()
520
555
  },
521
556
  )
522
557
 
558
+ watch(() => props.busy, scheduleAnnouncement)
559
+
523
560
  onBeforeUnmount(() => {
524
561
  clearTimeout(announceTimer)
525
562
  clearTimeout(typeaheadTimer)
@@ -548,7 +585,7 @@ const EMPTY_CLASSES = {
548
585
 
549
586
  const listboxClass = computed(() => [
550
587
  LISTBOX_CLASSES.base,
551
- hasContent.value && LISTBOX_CLASSES.filled,
588
+ (hasContent.value || !!slots.append) && LISTBOX_CLASSES.filled,
552
589
  ])
553
590
 
554
591
  defineExpose({
@@ -575,6 +612,7 @@ defineExpose({
575
612
  :tabindex="hasContent ? 0 : -1"
576
613
  :aria-label="ariaLabel"
577
614
  :aria-activedescendant="activeDescendantId"
615
+ :aria-busy="busy ? 'true' : undefined"
578
616
  :class="listboxClass"
579
617
  @keydown="onKeydown"
580
618
  @focus="scrollToItem(effectiveId)"
@@ -616,6 +654,8 @@ defineExpose({
616
654
  </template>
617
655
  </NexxtPickerItem>
618
656
  </NexxtPickerGroup>
657
+
658
+ <slot name="append" />
619
659
  </div>
620
660
 
621
661
  <Transition