@nexxtmove/ui 1.40.1 → 1.42.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.
package/dist/nuxt.js CHANGED
@@ -62,6 +62,7 @@ var s = {
62
62
  NexxtCountrySelect: "components/organisms/CountrySelect/CountrySelect.vue",
63
63
  NexxtDatePicker: "components/organisms/DatePicker/DatePicker.vue",
64
64
  NexxtDropdownButton: "components/organisms/DropdownButton/DropdownButton.vue",
65
+ NexxtFontPicker: "components/organisms/FontPicker/FontPicker.vue",
65
66
  NexxtHeader: "components/organisms/Header/Header.vue",
66
67
  NexxtModal: "components/organisms/Modal/Modal.vue",
67
68
  NexxtPickerPanel: "components/organisms/PickerPanel/PickerPanel.vue",
@@ -80,6 +81,7 @@ var s = {
80
81
  NexxtEditorBorder: "components/editor/molecules/EditorBorder/EditorBorder.vue",
81
82
  NexxtEditorColor: "components/editor/molecules/EditorColor/EditorColor.vue",
82
83
  NexxtEditorFlexDirection: "components/editor/molecules/EditorFlexDirection/EditorFlexDirection.vue",
84
+ NexxtEditorFontSize: "components/editor/molecules/EditorFontSize/EditorFontSize.vue",
83
85
  NexxtEditorGap: "components/editor/molecules/EditorGap/EditorGap.vue",
84
86
  NexxtEditorHeadingType: "components/editor/molecules/EditorHeadingType/EditorHeadingType.vue",
85
87
  NexxtEditorJustifyContent: "components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nexxtmove/ui",
3
3
  "type": "module",
4
- "version": "1.40.1",
4
+ "version": "1.42.0",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.ts",
@@ -3,5 +3,5 @@ defineOptions({ name: 'NexxtSkeleton' })
3
3
  </script>
4
4
 
5
5
  <template>
6
- <div class="animate-pulse rounded-sm bg-gray-200" />
6
+ <div class="animate-pulse rounded-sm bg-gray-200 motion-reduce:animate-none" />
7
7
  </template>
@@ -0,0 +1,253 @@
1
+ <script lang="ts">
2
+ /**
3
+ * The steps the slider can land on, as the font-size class they are stored as and the pixels that
4
+ * class is: every `--text-*` size of the theme, `text-xs` to `text-9xl`, with the px taken from
5
+ * `tailwind/nexxtmove.tailwind.theme.css` at 16px a rem. The size is absolute: a step replaces
6
+ * the size the text had.
7
+ *
8
+ * The consumer stores these classes and lists them in an `@source inline` rule, so adding or
9
+ * renaming a token is a breaking change even though the component API stays the same.
10
+ */
11
+ export const EDITOR_FONT_SIZE_STEPS = [
12
+ { token: 'text-xs', px: 12 },
13
+ { token: 'text-sm', px: 14 },
14
+ { token: 'text-base', px: 16 },
15
+ { token: 'text-lg', px: 18 },
16
+ { token: 'text-xl', px: 20 },
17
+ { token: 'text-2xl', px: 24 },
18
+ { token: 'text-3xl', px: 30 },
19
+ { token: 'text-4xl', px: 36 },
20
+ { token: 'text-5xl', px: 48 },
21
+ { token: 'text-6xl', px: 60 },
22
+ { token: 'text-7xl', px: 72 },
23
+ { token: 'text-8xl', px: 96 },
24
+ { token: 'text-9xl', px: 128 },
25
+ ] as const
26
+
27
+ /**
28
+ * The font size as the editor needs it: the Tailwind class itself, so the consumer can put the
29
+ * model value straight on the element it styles. "No size of its own" is `null` on the model, not
30
+ * a member of this type.
31
+ */
32
+ export type EditorFontSizeValue = (typeof EDITOR_FONT_SIZE_STEPS)[number]['token']
33
+
34
+ /** Overridable copy of the readout and the reset button. Every key is optional. */
35
+ export interface EditorFontSizeMessages {
36
+ /** Unit behind the pixel size. Defaults to `'px'`. */
37
+ unit?: string
38
+ /** Accessible name and tooltip of the reset button. Defaults to the Dutch `'Terug naar standaard'`. */
39
+ reset?: string
40
+ }
41
+
42
+ export interface NexxtEditorFontSizeProps {
43
+ /**
44
+ * The size the text has without a class of its own; the slider sits on its step while the
45
+ * model is `null`. Defaults to `text-sm`, the app's text base. Pass a token from
46
+ * `EDITOR_FONT_SIZE_STEPS`: one that is not on the list falls back to `text-sm`, and a
47
+ * development build warns once per value. The fallback is for existing or faulty data, not a
48
+ * feature to rely on.
49
+ */
50
+ defaultToken?: EditorFontSizeValue
51
+ /**
52
+ * Header text of the settings block. Defaults to the Dutch `'Grootte'`; the library has no i18n
53
+ * layer, so a consumer in another language passes its own copy in.
54
+ */
55
+ label?: string
56
+ /**
57
+ * Copy of the readout and the reset button. Merged per key, so overriding one key keeps the
58
+ * defaults of the others. Copy is never translated inside the component.
59
+ */
60
+ messages?: EditorFontSizeMessages
61
+ }
62
+
63
+ const DEFAULT_TOKEN = 'text-sm' satisfies EditorFontSizeValue
64
+
65
+ // A no-break space between number and unit, so a readout never wraps inside a value
66
+ const NBSP = '\u00a0'
67
+
68
+ const DEFAULT_MESSAGES = {
69
+ unit: 'px',
70
+ reset: 'Terug naar standaard',
71
+ } satisfies Required<EditorFontSizeMessages>
72
+ </script>
73
+
74
+ <script lang="ts" setup>
75
+ import { computed, useTemplateRef, watch } from 'vue'
76
+ import NexxtEditorSettingsBlock from '../../atoms/EditorSettingsBlock/EditorSettingsBlock.vue'
77
+ import NexxtIcon from '../../../atoms/Icon/Icon.vue'
78
+ import NexxtSlider from '../../../atoms/Slider/Slider.vue'
79
+ import NexxtTooltip from '../../../atoms/Tooltip/Tooltip.vue'
80
+
81
+ defineOptions({ name: 'NexxtEditorFontSize' })
82
+
83
+ const {
84
+ defaultToken = DEFAULT_TOKEN,
85
+ label = 'Grootte',
86
+ messages,
87
+ } = defineProps<NexxtEditorFontSizeProps>()
88
+
89
+ const emit = defineEmits<{
90
+ /**
91
+ * The slider was let go: the end of one drag, which a consumer can use to close an undo step.
92
+ * Arrow keys emit no pointerup.
93
+ */
94
+ pointerup: [event: PointerEvent]
95
+ /**
96
+ * The reset button was used, after the model was set to `null`. A consumer closes an undo step
97
+ * here, so a reset never merges into the drag before it.
98
+ */
99
+ reset: []
100
+ }>()
101
+
102
+ /**
103
+ * The chosen size, as the Tailwind class to apply. `null` -- also what an unset model is -- is no
104
+ * class of its own: the element keeps `defaultToken`. Moving the slider always writes a token,
105
+ * the default step included; only the reset button writes `null`.
106
+ *
107
+ * The app should only write tokens from `EDITOR_FONT_SIZE_STEPS`. A value that is not on the list
108
+ * (existing or faulty data, `text-[15px]` say) is shown on the default step, keeps the reset
109
+ * button visible so the user can get back to a clean `null`, and makes a development build warn
110
+ * once per value. It is never rewritten until the user picks a step.
111
+ */
112
+ const model = defineModel<EditorFontSizeValue | null>({ default: null })
113
+
114
+ const copy = computed(() => ({ ...DEFAULT_MESSAGES, ...messages }))
115
+
116
+ const root = useTemplateRef<HTMLElement>('root')
117
+
118
+ const maxIndex = EDITOR_FONT_SIZE_STEPS.length - 1
119
+
120
+ const indexOf = (token: string | null | undefined) =>
121
+ EDITOR_FONT_SIZE_STEPS.findIndex((step) => step.token === token)
122
+
123
+ /** Step of the default size; an unknown `defaultToken` falls back to `text-sm`. */
124
+ const defaultIndex = computed(() => {
125
+ const index = indexOf(defaultToken)
126
+ return index === -1 ? indexOf(DEFAULT_TOKEN) : index
127
+ })
128
+
129
+ /**
130
+ * Position of the current value on the step list. `null`, and a value that is not on the list (a
131
+ * size outside the theme, or one that no longer exists), sit on the default step instead of
132
+ * leaving the thumb off the track; the first change writes a valid class back.
133
+ */
134
+ const stepIndex = computed(() => {
135
+ const index = indexOf(model.value)
136
+ return index === -1 ? defaultIndex.value : index
137
+ })
138
+
139
+ // The same guard as NexxtFontPicker: `process.env.NODE_ENV` stays in the library build, so the
140
+ // consumer's bundler decides; `import.meta.env.DEV` would be fixed when the library is built.
141
+ // Once per value per instance: a re-render does not repeat it, a new instance starts afresh.
142
+ if (process.env.NODE_ENV !== 'production') {
143
+ const warned = new Set<string>()
144
+ const warnOnce = (key: string, message: string) => {
145
+ if (warned.has(key)) return
146
+ warned.add(key)
147
+ console.warn(`[NexxtEditorFontSize] ${message}`)
148
+ }
149
+
150
+ watch(
151
+ () => defaultToken,
152
+ (token) => {
153
+ if (indexOf(token) === -1) {
154
+ warnOnce(
155
+ `default:${token}`,
156
+ `defaultToken "${token}" is not in EDITOR_FONT_SIZE_STEPS; falling back to ${DEFAULT_TOKEN}.`,
157
+ )
158
+ }
159
+ },
160
+ { immediate: true },
161
+ )
162
+
163
+ watch(
164
+ model,
165
+ (value) => {
166
+ if (value !== null && indexOf(value) === -1) {
167
+ warnOnce(
168
+ `model:${value}`,
169
+ `"${value}" is not in EDITOR_FONT_SIZE_STEPS; showing the default step until a step is picked.`,
170
+ )
171
+ }
172
+ },
173
+ { immediate: true },
174
+ )
175
+ }
176
+
177
+ // The slider moves over the index, not over the px value, so its model is translated both ways.
178
+ // Every step writes its token, the default one too: `null` is the reset button's alone.
179
+ const sliderValue = computed({
180
+ get: () => [stepIndex.value],
181
+ set: ([index]) => {
182
+ model.value = EDITOR_FONT_SIZE_STEPS[index].token
183
+ },
184
+ })
185
+
186
+ const displayValue = computed(
187
+ () => `${EDITOR_FONT_SIZE_STEPS[stepIndex.value].px}${NBSP}${copy.value.unit}`,
188
+ )
189
+
190
+ // The button turns invisible under the focus it has, which would drop focus on <body>, so it is
191
+ // handed to the thumb: the control the user is most likely to reach for next.
192
+ const onReset = () => {
193
+ model.value = null
194
+ emit('reset')
195
+ root.value?.querySelector<HTMLElement>('[role="slider"]')?.focus()
196
+ }
197
+
198
+ const RESET_CLASSES = {
199
+ base: 'flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-gray-700 hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cornflower-blue-500',
200
+ hidden: 'invisible',
201
+ }
202
+
203
+ // Only `null` hides it: an explicit token on the default step is still a value of its own
204
+ const resetClass = computed(() => [
205
+ RESET_CLASSES.base,
206
+ model.value === null && RESET_CLASSES.hidden,
207
+ ])
208
+ </script>
209
+
210
+ <template>
211
+ <NexxtEditorSettingsBlock :label="label">
212
+ <div ref="root" class="flex items-center gap-4">
213
+ <!--
214
+ Same set-up as NexxtEditorLineHeight: the slider walks the step index, so the readout is
215
+ rendered next to it and the thumb announces the readout through `aria-valuetext` instead
216
+ of the index. The block label is a <p>, so the name goes on the thumb explicitly.
217
+ -->
218
+ <NexxtSlider
219
+ v-model="sliderValue"
220
+ :min="0"
221
+ :max="maxIndex"
222
+ :step="1"
223
+ :aria-label="label"
224
+ :aria-valuetext="displayValue"
225
+ @pointerup="emit('pointerup', $event)"
226
+ />
227
+
228
+ <!--
229
+ `min-w-14` (56px) fits the widest readout, "128 px" (45px in Chromium with tabular
230
+ digits), with room to spare for wider digits in another font or browser, so the slider
231
+ keeps its width over the whole list; the ReadoutWidth story measures it.
232
+ -->
233
+ <span
234
+ class="min-w-14 shrink-0 text-right small-normal whitespace-nowrap text-gray-700 tabular-nums"
235
+ >
236
+ {{ displayValue }}
237
+ </span>
238
+
239
+ <!--
240
+ Always rendered and only `invisible` without a value of its own: it keeps its 28px, so the
241
+ slider never jumps, and an invisible button is out of the tab order.
242
+ -->
243
+ <NexxtTooltip>
244
+ <button type="button" :aria-label="copy.reset" :class="resetClass" @click="onReset">
245
+ <NexxtIcon name="arrow-rotate-left" aria-hidden="true" />
246
+ </button>
247
+ <template #tooltip>
248
+ <span class="extra-small-normal text-gray-900">{{ copy.reset }}</span>
249
+ </template>
250
+ </NexxtTooltip>
251
+ </div>
252
+ </NexxtEditorSettingsBlock>
253
+ </template>
@@ -1,29 +1,32 @@
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. Not a scale Tailwind has names for -- `tracking-wide`
5
- * is an em and moves with the font size, and the editor shows every size in
6
- * px -- so every step is an arbitrary-value class.
6
+ * The steps the slider can land on, as the class they are stored as and the em that class is.
7
+ * In em, so the spacing moves with the font size: a fixed 2px does nothing at 128px. Not a scale
8
+ * Tailwind has names for (`tracking-wide` is 0.025em, but there is no -0.01em), so every step is
9
+ * an arbitrary-value class.
7
10
  *
8
11
  * The middle step is no class at all: zero is what the text already does, and
9
12
  * storing nothing for it keeps the "no class means default" every other
10
13
  * setting has. The negative side is there on purpose: tightening a large
11
14
  * heading is a real need.
12
15
  *
13
- * The consumer stores these classes and lists them in an `@source inline` rule,
14
- * so adding or renaming a token is a breaking change even though the component
15
- * API stays the same.
16
+ * The consumer stores these classes and lists them in an `@source inline` rule (and the back end
17
+ * allows exactly these), so adding or renaming a token is a breaking change even though the
18
+ * component API stays the same.
16
19
  */
17
20
  export const EDITOR_LETTER_SPACING_STEPS = [
18
- { token: 'tracking-[-1px]', px: -1 },
19
- { token: 'tracking-[-0.5px]', px: -0.5 },
20
- { token: 'tracking-[-0.25px]', px: -0.25 },
21
- { token: null, px: 0 },
22
- { token: 'tracking-[0.25px]', px: 0.25 },
23
- { token: 'tracking-[0.5px]', px: 0.5 },
24
- { token: 'tracking-[1px]', px: 1 },
25
- { token: 'tracking-[1.5px]', px: 1.5 },
26
- { token: 'tracking-[2px]', px: 2 },
21
+ { token: 'tracking-[-0.05em]', em: -0.05 },
22
+ { token: 'tracking-[-0.025em]', em: -0.025 },
23
+ { token: 'tracking-[-0.01em]', em: -0.01 },
24
+ { token: null, em: 0 },
25
+ { token: 'tracking-[0.01em]', em: 0.01 },
26
+ { token: 'tracking-[0.025em]', em: 0.025 },
27
+ { token: 'tracking-[0.05em]', em: 0.05 },
28
+ { token: 'tracking-[0.075em]', em: 0.075 },
29
+ { token: 'tracking-[0.1em]', em: 0.1 },
27
30
  ] as const
28
31
 
29
32
  /**
@@ -44,6 +47,13 @@ export interface EditorLetterSpacingMessages {
44
47
  }
45
48
 
46
49
  export interface NexxtEditorLetterSpacingProps {
50
+ /**
51
+ * The `text-*` token the text renders at on the screen on show (for a heading, the token of
52
+ * its heading type): the base the readout turns the em into px with. Defaults to `text-sm`,
53
+ * the app's text base. A token that is not in `EDITOR_FONT_SIZE_STEPS` falls back to
54
+ * `text-sm`, and a development build warns once per value.
55
+ */
56
+ fontSize?: EditorFontSizeValue
47
57
  /**
48
58
  * Header text of the settings block. Defaults to the Dutch
49
59
  * `'Letterafstand'`; the library has no i18n layer, so a consumer in another
@@ -57,6 +67,15 @@ export interface NexxtEditorLetterSpacingProps {
57
67
  messages?: EditorLetterSpacingMessages
58
68
  }
59
69
 
70
+ const DEFAULT_FONT_SIZE = 'text-sm' satisfies EditorFontSizeValue
71
+
72
+ /**
73
+ * Rounds to one decimal, half away from zero. The product is snapped to 6 decimals first, so a
74
+ * float such as 0.075 * 18 = 1.3499999… rounds as the 1.35 it is, to 1.4.
75
+ */
76
+ const roundTenths = (value: number) =>
77
+ (Math.sign(value) * Math.round(Number((Math.abs(value) * 10).toFixed(6)))) / 10
78
+
60
79
  const DEFAULT_MESSAGES = {
61
80
  unit: 'px',
62
81
  decimalSeparator: ',',
@@ -67,13 +86,17 @@ const DEFAULT_INDEX = EDITOR_LETTER_SPACING_STEPS.findIndex(({ token }) => token
67
86
  </script>
68
87
 
69
88
  <script lang="ts" setup>
70
- import { computed } from 'vue'
89
+ import { computed, watch } from 'vue'
71
90
  import NexxtEditorSettingsBlock from '../../atoms/EditorSettingsBlock/EditorSettingsBlock.vue'
72
91
  import NexxtSlider from '../../../atoms/Slider/Slider.vue'
73
92
 
74
93
  defineOptions({ name: 'NexxtEditorLetterSpacing' })
75
94
 
76
- const { label = 'Letterafstand', messages } = defineProps<NexxtEditorLetterSpacingProps>()
95
+ const {
96
+ fontSize = DEFAULT_FONT_SIZE,
97
+ label = 'Letterafstand',
98
+ messages,
99
+ } = defineProps<NexxtEditorLetterSpacingProps>()
77
100
 
78
101
  const emit = defineEmits<{
79
102
  /**
@@ -86,6 +109,10 @@ const emit = defineEmits<{
86
109
  /**
87
110
  * The selected letter spacing, as the Tailwind class to apply. `null` -- also
88
111
  * what an unset model is -- is the default step: no class at all, zero.
112
+ *
113
+ * A value that is not on the list (an old px class such as `tracking-[1px]`, say) is shown on
114
+ * the default step and makes a development build warn once per value; the first change writes a
115
+ * valid class back.
89
116
  */
90
117
  const model = defineModel<EditorLetterSpacingValue | null>({ default: null })
91
118
 
@@ -93,20 +120,63 @@ const copy = computed(() => ({ ...DEFAULT_MESSAGES, ...messages }))
93
120
 
94
121
  const maxIndex = EDITOR_LETTER_SPACING_STEPS.length - 1
95
122
 
123
+ const indexOf = (token: string | null | undefined) =>
124
+ EDITOR_LETTER_SPACING_STEPS.findIndex((step) => step.token === token)
125
+
126
+ const fontSizeStep = (token: string) => EDITOR_FONT_SIZE_STEPS.find((step) => step.token === token)
127
+
128
+ /** The size in px the readout is based on; an unknown `fontSize` falls back to `text-sm`. */
129
+ const basePx = computed(() => (fontSizeStep(fontSize) ?? fontSizeStep(DEFAULT_FONT_SIZE)!).px)
130
+
96
131
  /**
97
132
  * Position of the current value on the step list. A value that is not on the
98
- * list -- a stored `tracking-wide`, say -- falls back to the default step
99
- * rather than the first one: the first step here is the tightest spacing, and
100
- * an unknown value is no reason to show text as squeezed. The first change
101
- * writes a valid class back.
133
+ * list falls back to the default step rather than the first one: the first step here is the
134
+ * tightest spacing, and an unknown value is no reason to show text as squeezed.
102
135
  */
103
136
  const stepIndex = computed(() => {
104
- const index = EDITOR_LETTER_SPACING_STEPS.findIndex(({ token }) => token === model.value)
137
+ const index = indexOf(model.value)
105
138
 
106
139
  return index === -1 ? DEFAULT_INDEX : index
107
140
  })
108
141
 
109
- // The slider moves over the index, not over the px value, so its model is
142
+ // The same guard as NexxtEditorFontSize: `process.env.NODE_ENV` stays in the library build, so
143
+ // the consumer's bundler decides. Once per value per instance.
144
+ if (process.env.NODE_ENV !== 'production') {
145
+ const warned = new Set<string>()
146
+ const warnOnce = (key: string, message: string) => {
147
+ if (warned.has(key)) return
148
+ warned.add(key)
149
+ console.warn(`[NexxtEditorLetterSpacing] ${message}`)
150
+ }
151
+
152
+ watch(
153
+ () => fontSize,
154
+ (token) => {
155
+ if (!fontSizeStep(token)) {
156
+ warnOnce(
157
+ `fontSize:${token}`,
158
+ `fontSize "${token}" is not in EDITOR_FONT_SIZE_STEPS; falling back to ${DEFAULT_FONT_SIZE}.`,
159
+ )
160
+ }
161
+ },
162
+ { immediate: true },
163
+ )
164
+
165
+ watch(
166
+ model,
167
+ (value) => {
168
+ if (value !== null && indexOf(value) === -1) {
169
+ warnOnce(
170
+ `model:${value}`,
171
+ `"${value}" is not in EDITOR_LETTER_SPACING_STEPS; showing the default step until a step is picked.`,
172
+ )
173
+ }
174
+ },
175
+ { immediate: true },
176
+ )
177
+ }
178
+
179
+ // The slider moves over the index, not over the em value, so its model is
110
180
  // translated both ways here.
111
181
  const sliderValue = computed({
112
182
  get: () => [stepIndex.value],
@@ -115,11 +185,15 @@ const sliderValue = computed({
115
185
  },
116
186
  })
117
187
 
118
- /** `-0,5 px`: a plain hyphen-minus for the sign, the decimal separator from the copy. */
188
+ /**
189
+ * The spacing in px at the current size, to one decimal: `-0,7 px` for -0.05em at 14px. A plain
190
+ * hyphen-minus for the sign, the decimal separator from the copy; `+ 0` turns a -0 into 0.
191
+ */
119
192
  const displayValue = computed(() => {
120
- const { px } = EDITOR_LETTER_SPACING_STEPS[stepIndex.value]
193
+ const { em } = EDITOR_LETTER_SPACING_STEPS[stepIndex.value]
194
+ const px = String(roundTenths(em * basePx.value) + 0)
121
195
 
122
- return `${String(px).replace('.', copy.value.decimalSeparator)} ${copy.value.unit}`
196
+ return `${px.replace('.', copy.value.decimalSeparator)} ${copy.value.unit}`
123
197
  })
124
198
  </script>
125
199
 
@@ -147,7 +221,7 @@ const displayValue = computed(() => {
147
221
 
148
222
  <!--
149
223
  `min-w-16` rather than the `min-w-12` of the other editor sliders:
150
- `-0,25 px` does not fit in 12. That plus `shrink-0` fixes the width of
224
+ `-6,4 px` and `12,8 px`, the ends at text-9xl, do not fit in 12. That plus `shrink-0` fixes the width of
151
225
  the box, so the slider keeps its size while dragging; `tabular-nums`
152
226
  only stops the digits from jittering inside it.
153
227
  gray-700, not the gray-500 Slider uses for its own readout: axe puts