@nexxtmove/ui 1.45.0 → 1.46.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 (27) hide show
  1. package/dist/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue.d.ts +2 -2
  2. package/dist/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.vue.d.ts +45 -0
  3. package/dist/components/editor/atoms/EditorSettingsGroup/editorSettingsGroupContext.d.ts +23 -0
  4. package/dist/components/editor/molecules/EditorAlignItems/EditorAlignItems.vue.d.ts +6 -0
  5. package/dist/components/editor/molecules/EditorDashPattern/EditorDashPattern.vue.d.ts +61 -0
  6. package/dist/components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue.d.ts +6 -0
  7. package/dist/components/editor/molecules/EditorLineStyle/EditorLineStyle.vue.d.ts +78 -0
  8. package/dist/components/editor/molecules/EditorPadding/EditorPadding.vue.d.ts +11 -0
  9. package/dist/components/editor/molecules/EditorTextAlign/EditorTextAlign.vue.d.ts +8 -0
  10. package/dist/components/molecules/ToggleGroup/ToggleGroup.vue.d.ts +22 -1
  11. package/dist/index.d.ts +9 -0
  12. package/dist/index.js +2201 -1873
  13. package/dist/nuxt.js +3 -0
  14. package/package.json +1 -1
  15. package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue +36 -5
  16. package/src/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.vue +228 -0
  17. package/src/components/editor/atoms/EditorSettingsGroup/editorSettingsGroupContext.ts +27 -0
  18. package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.vue +16 -2
  19. package/src/components/editor/molecules/EditorDashPattern/EditorDashPattern.vue +139 -0
  20. package/src/components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue +16 -2
  21. package/src/components/editor/molecules/EditorLineStyle/EditorLineStyle.vue +230 -0
  22. package/src/components/editor/molecules/EditorPadding/EditorPadding.vue +102 -17
  23. package/src/components/editor/molecules/EditorTextAlign/EditorTextAlign.vue +18 -2
  24. package/src/components/molecules/ToggleGroup/ToggleGroup.vue +30 -8
  25. package/src/components.json +3 -0
  26. package/src/index.ts +22 -0
  27. package/tailwind/nexxtmove.tailwind.custom.css +6 -0
@@ -0,0 +1,230 @@
1
+ <script lang="ts">
2
+ /**
3
+ * The line styles the editor offers, in the order they are shown. The model is
4
+ * the token itself, not a class: what a style puts on the element is more than
5
+ * one class and depends on the thickness and the breakpoint, so that
6
+ * translation belongs to the consumer. The labels arrive through `messages`,
7
+ * keyed by token.
8
+ */
9
+ export const EDITOR_LINE_STYLE_OPTIONS = [
10
+ { token: 'none' },
11
+ { token: 'solid' },
12
+ { token: 'dashed' },
13
+ { token: 'dotted' },
14
+ ] as const
15
+
16
+ /** A line style, as one of the tokens of `EDITOR_LINE_STYLE_OPTIONS`. */
17
+ export type EditorLineStyleValue = (typeof EDITOR_LINE_STYLE_OPTIONS)[number]['token']
18
+
19
+ /**
20
+ * The thicknesses the slider can land on, in px. There is no Tailwind token
21
+ * behind them: the consumer writes the value into a CSS variable.
22
+ */
23
+ export const EDITOR_LINE_THICKNESS_STEPS = [1, 2, 3, 4, 6, 8] as const
24
+
25
+ /** A line thickness in px, as one of `EDITOR_LINE_THICKNESS_STEPS`. */
26
+ export type EditorLineThickness = (typeof EDITOR_LINE_THICKNESS_STEPS)[number]
27
+
28
+ /**
29
+ * The smallest thickness per style. A style that is not in here starts at the
30
+ * first step. Dotted starts at 2: at 1 px a round dot is a blurred pixel.
31
+ */
32
+ export const EDITOR_LINE_MIN_THICKNESS: Partial<Record<EditorLineStyleValue, EditorLineThickness>> =
33
+ { dotted: 2 }
34
+
35
+ /** Overridable copy of the options and the thickness row. Every key is optional. */
36
+ export interface EditorLineStyleMessages {
37
+ /** Name of the `none` option. Defaults to `'Geen'`. */
38
+ none?: string
39
+ /** Name of the `solid` option. Defaults to `'Doorgetrokken'`. */
40
+ solid?: string
41
+ /** Name of the `dashed` option. Defaults to `'Gestreept'`. */
42
+ dashed?: string
43
+ /** Name of the `dotted` option. Defaults to `'Gestippeld'`. */
44
+ dotted?: string
45
+ /** Heading above the slider, and the accessible name of its thumb. Defaults to `'Dikte'`. */
46
+ thickness?: string
47
+ /** Unit shown behind the thickness. Defaults to `'px'`. */
48
+ unit?: string
49
+ }
50
+
51
+ export interface NexxtEditorLineStyleProps {
52
+ /**
53
+ * Header text of the settings block, and with it the name of the option
54
+ * group. Defaults to the Dutch `'Lijn'`; the library has no i18n layer, so a
55
+ * consumer in another language passes its own copy in.
56
+ */
57
+ label?: string
58
+ /**
59
+ * Copy of the options and the thickness row. Defaults are Dutch and are
60
+ * merged per key, so overriding one keeps the defaults of the others. Copy is
61
+ * never translated inside the component.
62
+ */
63
+ messages?: EditorLineStyleMessages
64
+ }
65
+
66
+ const DEFAULT_MESSAGES = {
67
+ none: 'Geen',
68
+ solid: 'Doorgetrokken',
69
+ dashed: 'Gestreept',
70
+ dotted: 'Gestippeld',
71
+ thickness: 'Dikte',
72
+ unit: 'px',
73
+ } satisfies Required<EditorLineStyleMessages>
74
+
75
+ /** The border style each drawn swatch uses; `none` shows an icon instead. */
76
+ const SWATCH: Record<Exclude<EditorLineStyleValue, 'none'>, string> = {
77
+ solid: 'border-solid',
78
+ dashed: 'border-dashed',
79
+ dotted: 'border-dotted',
80
+ }
81
+ </script>
82
+
83
+ <script lang="ts" setup>
84
+ import { computed } from 'vue'
85
+ import NexxtEditorSettingsBlock from '../../atoms/EditorSettingsBlock/EditorSettingsBlock.vue'
86
+ import NexxtIcon from '../../../atoms/Icon/Icon.vue'
87
+ import NexxtSlider from '../../../atoms/Slider/Slider.vue'
88
+ import NexxtToggleGroup from '../../../molecules/ToggleGroup/ToggleGroup.vue'
89
+ import type { NexxtToggleGroupOption } from '../../../molecules/ToggleGroup/ToggleGroup.vue'
90
+
91
+ defineOptions({ name: 'NexxtEditorLineStyle' })
92
+
93
+ const { label = 'Lijn', messages } = defineProps<NexxtEditorLineStyleProps>()
94
+
95
+ /** The selected line style, as a token. */
96
+ const model = defineModel<EditorLineStyleValue>({ required: true })
97
+
98
+ /** The line thickness in px. */
99
+ const thickness = defineModel<EditorLineThickness>('thickness', { required: true })
100
+
101
+ const copy = computed(() => ({ ...DEFAULT_MESSAGES, ...messages }))
102
+
103
+ // Drawings only and deliberately without tooltips, like the other editor
104
+ // toggle groups (TextAlign, JustifyContent, AlignItems, BackgroundSize). The
105
+ // copy travels as `ariaLabel`, so every option still has an accessible name.
106
+ const options = computed<NexxtToggleGroupOption[]>(() =>
107
+ EDITOR_LINE_STYLE_OPTIONS.map(({ token }) => ({
108
+ value: token,
109
+ ariaLabel: copy.value[token],
110
+ })),
111
+ )
112
+
113
+ const swatchOf = (value: string) => SWATCH[value as Exclude<EditorLineStyleValue, 'none'>]
114
+
115
+ // ToggleGroup types its model as the open `string` of its options. The only
116
+ // values it can emit are the tokens built above, so the narrowing is safe.
117
+ const selected = computed({
118
+ get: () => model.value,
119
+ set: (value: string) => {
120
+ model.value = value as EditorLineStyleValue
121
+ },
122
+ })
123
+
124
+ /** The steps the current style allows: dotted skips the ones below its minimum. */
125
+ const steps = computed(() => {
126
+ const min = EDITOR_LINE_MIN_THICKNESS[model.value] ?? EDITOR_LINE_THICKNESS_STEPS[0]
127
+ return EDITOR_LINE_THICKNESS_STEPS.filter((step) => step >= min)
128
+ })
129
+
130
+ /**
131
+ * Position of the thickness on the allowed steps. A value that is not on them
132
+ * -- a 5 from outside, or a 1 while the style is dotted -- shows as the first
133
+ * allowed step, and is deliberately not written back: the consumer bumps the
134
+ * thickness in the same patch as the style change, so it stays one undo step.
135
+ */
136
+ const stepIndex = computed(() => Math.max(0, steps.value.indexOf(thickness.value)))
137
+
138
+ // The slider moves over the index, not over the px value.
139
+ const sliderValue = computed({
140
+ get: () => [stepIndex.value],
141
+ set: ([index]) => {
142
+ thickness.value = steps.value[index]
143
+ },
144
+ })
145
+
146
+ const displayValue = computed(() => `${steps.value[stepIndex.value]} ${copy.value.unit}`)
147
+
148
+ const open = computed(() => model.value !== 'none')
149
+
150
+ // `:inert="false"` renders `inert="false"`, which still makes the row inert;
151
+ // only `undefined` removes the attribute.
152
+ const inert = computed(() => (open.value ? undefined : true))
153
+ </script>
154
+
155
+ <template>
156
+ <NexxtEditorSettingsBlock :label="label">
157
+ <!--
158
+ The block label is not a <label> (a <p>, or a heading when promoted), so
159
+ the group is named explicitly. Both attributes fall through to
160
+ ToggleGroup's root.
161
+ -->
162
+ <NexxtToggleGroup v-model="selected" :options="options" role="group" :aria-label="label">
163
+ <!--
164
+ Rendered on the button and again in the overlay that shows the active
165
+ colour, so it draws in currentColor and takes ToggleGroup's colours:
166
+ gray-700 at rest, gray-900 on hover, the active colour when selected.
167
+ -->
168
+ <template #option="{ option }">
169
+ <NexxtIcon v-if="option.value === 'none'" name="ban" />
170
+ <span
171
+ v-else
172
+ aria-hidden="true"
173
+ class="block h-0 w-6 border-t-2 border-current"
174
+ :class="swatchOf(option.value)"
175
+ />
176
+ </template>
177
+ </NexxtToggleGroup>
178
+
179
+ <!--
180
+ Collapses when there is no line: there is no thickness to set. The row
181
+ stays mounted so its height can animate, and `inert` keeps the slider out
182
+ of the tab order meanwhile.
183
+ -->
184
+ <div
185
+ data-testid="editor-line-style-thickness"
186
+ :inert="inert"
187
+ :class="[
188
+ 'grid transition-[grid-template-rows] duration-200 ease-out-strong motion-reduce:transition-none',
189
+ open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
190
+ ]"
191
+ >
192
+ <!--
193
+ `-mx-1` / `px-1` give the thumb's focus ring room inside the clipping
194
+ box, the same trick as EditorPadding's second row.
195
+ -->
196
+ <div
197
+ :class="[
198
+ '-mx-1 min-h-0 overflow-hidden transition-opacity duration-200 ease-out-strong motion-reduce:transition-none',
199
+ open ? 'opacity-100' : 'opacity-0',
200
+ ]"
201
+ >
202
+ <!--
203
+ Dikte is a setting of the line in its own right, not a field of the
204
+ style: setting typography and spacing (gray-900, a 16px step up), all
205
+ of it inside the clipping box so a collapsed row leaves nothing behind.
206
+ -->
207
+ <div class="flex flex-col gap-1 px-1 pt-4 pb-1">
208
+ <p class="small-normal text-gray-900">{{ copy.thickness }}</p>
209
+ <div class="flex items-center gap-4">
210
+ <!--
211
+ The slider's model is the step index, so the readout is rendered
212
+ here and `aria-valuetext` says the px instead of the index.
213
+ -->
214
+ <NexxtSlider
215
+ v-model="sliderValue"
216
+ :min="0"
217
+ :max="steps.length - 1"
218
+ :step="1"
219
+ :aria-label="copy.thickness"
220
+ :aria-valuetext="displayValue"
221
+ />
222
+ <span class="min-w-12 shrink-0 text-right small-normal whitespace-nowrap text-gray-700">
223
+ {{ displayValue }}
224
+ </span>
225
+ </div>
226
+ </div>
227
+ </div>
228
+ </div>
229
+ </NexxtEditorSettingsBlock>
230
+ </template>
@@ -98,6 +98,40 @@ export interface NexxtEditorPaddingProps {
98
98
  * is never translated inside the component.
99
99
  */
100
100
  messages?: EditorPaddingMessages
101
+ /**
102
+ * Which sides the block edits. `'all'` offers all four. `'vertical'` offers
103
+ * top and bottom only, for an element where horizontal padding has no
104
+ * sensible effect: collapsed it is one field that writes `py-N`, expanded it
105
+ * is Boven and Onder in the same row, written as `pt-N pb-N`. Horizontal
106
+ * padding in the incoming value is ignored there and drops out on the first
107
+ * write. `'horizontal'` is the mirror image: one field writing `px-N`, or
108
+ * Links and Rechts in one row written as `pl-N pr-N`, with incoming vertical
109
+ * padding ignored. Defaults to `'all'`.
110
+ */
111
+ sides?: 'all' | 'vertical' | 'horizontal'
112
+ }
113
+
114
+ /** The one axis a two-sided block edits. */
115
+ type PaddingAxis = 'vertical' | 'horizontal'
116
+
117
+ /**
118
+ * Per axis: the two sides in reading order (the first is the one the collapsed
119
+ * field reads, and the one the other follows on closing), the collapsed
120
+ * field's message key, and the utility prefixes it writes.
121
+ */
122
+ const AXES: Record<
123
+ PaddingAxis,
124
+ { sides: [PaddingIconSide, PaddingIconSide]; ignored: PaddingIconSide[]; both: string }
125
+ > = {
126
+ vertical: { sides: ['top', 'bottom'], ignored: ['left', 'right'], both: 'py' },
127
+ horizontal: { sides: ['left', 'right'], ignored: ['top', 'bottom'], both: 'px' },
128
+ }
129
+
130
+ const SIDE_PREFIX: Record<PaddingIconSide, string> = {
131
+ top: 'pt',
132
+ right: 'pr',
133
+ bottom: 'pb',
134
+ left: 'pl',
101
135
  }
102
136
 
103
137
  const DEFAULT_MESSAGES = {
@@ -129,7 +163,7 @@ const TOKEN = /^p([xytrbl]?)-(\d+)$/
129
163
  * padding utility on the step list is ignored, and a later token wins over an
130
164
  * earlier one -- the same thing Tailwind's own cascade does for `p-4 pt-2`.
131
165
  */
132
- const parse = (value: string): PaddingSides => {
166
+ const parse = (value: string, axis?: PaddingAxis): PaddingSides => {
133
167
  const sides: PaddingSides = { top: 0, right: 0, bottom: 0, left: 0 }
134
168
 
135
169
  for (const token of value.split(/\s+/)) {
@@ -142,6 +176,11 @@ const parse = (value: string): PaddingSides => {
142
176
  for (const side of PREFIX_SIDES[match[1]]) sides[side] = step
143
177
  }
144
178
 
179
+ // A two-sided block shows no field for the other axis, so whatever the value
180
+ // says there is not part of what it edits. Zeroed rather than kept, so nothing
181
+ // hidden can make an edit look like no edit, and nothing of it is written back.
182
+ if (axis) for (const side of AXES[axis].ignored) sides[side] = 0
183
+
145
184
  return sides
146
185
  }
147
186
 
@@ -152,6 +191,19 @@ const serialize = ({ top, right, bottom, left }: PaddingSides): string => {
152
191
  return `pt-${top} pr-${right} pb-${bottom} pl-${left}`
153
192
  }
154
193
 
194
+ /**
195
+ * Write the two sides of a two-sided block back. Which form depends on what is
196
+ * on screen rather than on the values: the one collapsed field writes `py-N` /
197
+ * `px-N`, the expanded pair writes `pt-N pb-N` / `pl-N pr-N` even while the two
198
+ * agree.
199
+ */
200
+ const serializeAxis = (sides: PaddingSides, axis: PaddingAxis, expanded: boolean): string => {
201
+ const [first, second] = AXES[axis].sides
202
+ return expanded
203
+ ? `${SIDE_PREFIX[first]}-${sides[first]} ${SIDE_PREFIX[second]}-${sides[second]}`
204
+ : `${AXES[axis].both}-${sides[first]}`
205
+ }
206
+
155
207
  /**
156
208
  * Turn a px value into a step: clamp to 0-384 and take the nearest one. A tie
157
209
  * goes to the lower step, so 14 px becomes 12 and 60 px becomes 56; that way a
@@ -183,20 +235,27 @@ import PaddingIcon from './_PaddingIcon.vue'
183
235
 
184
236
  defineOptions({ name: 'NexxtEditorPadding' })
185
237
 
186
- const { label = 'Padding', messages } = defineProps<NexxtEditorPaddingProps>()
238
+ const {
239
+ label = 'Padding',
240
+ messages,
241
+ sides: offered = 'all',
242
+ } = defineProps<NexxtEditorPaddingProps>()
187
243
 
188
244
  /** The padding, as the Tailwind class string to apply. */
189
245
  const model = defineModel<string>({ required: true })
190
246
 
191
247
  const copy = computed(() => ({ ...DEFAULT_MESSAGES, ...messages }))
192
248
 
193
- const sides = ref<PaddingSides>(parse(model.value))
249
+ /** The axis of a two-sided block, or `undefined` for one that edits all four sides. */
250
+ const axis = computed<PaddingAxis | undefined>(() => (offered === 'all' ? undefined : offered))
251
+
252
+ const values = ref<PaddingSides>(parse(model.value, axis.value))
194
253
 
195
254
  // The model is the source of truth, so a change from outside re-reads it. The
196
255
  // open state is deliberately left alone: collapsing is a view choice of the
197
256
  // user, and a re-parse mid-edit should not fold the four fields away.
198
- watch(model, (value) => {
199
- sides.value = parse(value)
257
+ watch([model, axis], ([value, current]) => {
258
+ values.value = parse(value, current)
200
259
  })
201
260
 
202
261
  /**
@@ -204,7 +263,7 @@ watch(model, (value) => {
204
263
  * cannot be expressed with two fields; from then on the button owns it.
205
264
  */
206
265
  const expanded = ref(
207
- sides.value.top !== sides.value.bottom || sides.value.left !== sides.value.right,
266
+ values.value.top !== values.value.bottom || values.value.left !== values.value.right,
208
267
  )
209
268
 
210
269
  interface PaddingField {
@@ -227,8 +286,22 @@ const singleSideField = (side: PaddingIconSide): PaddingField => ({
227
286
  * The row that is always visible. Its two fields swap meaning the instant the
228
287
  * toggle flips -- only the second row animates, so the block never jumps.
229
288
  */
230
- const primaryFields = computed<PaddingField[]>(() =>
231
- expanded.value
289
+ const primaryFields = computed<PaddingField[]>(() => {
290
+ if (axis.value) {
291
+ const [first, second] = AXES[axis.value].sides
292
+ return expanded.value
293
+ ? [singleSideField(first), singleSideField(second)]
294
+ : [
295
+ {
296
+ key: axis.value,
297
+ targets: [first, second],
298
+ source: first,
299
+ label: copy.value[axis.value],
300
+ },
301
+ ]
302
+ }
303
+
304
+ return expanded.value
232
305
  ? [singleSideField('top'), singleSideField('right')]
233
306
  : [
234
307
  {
@@ -238,8 +311,8 @@ const primaryFields = computed<PaddingField[]>(() =>
238
311
  label: copy.value.horizontal,
239
312
  },
240
313
  { key: 'vertical', targets: ['top', 'bottom'], source: 'top', label: copy.value.vertical },
241
- ],
242
- )
314
+ ]
315
+ })
243
316
 
244
317
  /** The row that slides open. Stays mounted so its height can be animated. */
245
318
  const secondaryFields = computed<PaddingField[]>(() => [
@@ -257,20 +330,20 @@ const secondaryInert = computed(() => (expanded.value ? undefined : true))
257
330
  // The fields speak px, not steps: `p-6` reads as 24. A 0 is shown as an empty
258
331
  // field so the placeholder carries it, and an empty field commits back as a 0.
259
332
  const display = (side: PaddingIconSide) =>
260
- sides.value[side] === 0 ? '' : String(pxOf(sides.value[side]))
333
+ values.value[side] === 0 ? '' : String(pxOf(values.value[side]))
261
334
 
262
335
  const apply = (next: PaddingSides) => {
263
336
  // A change that changes nothing -- committing 30 where the value is already
264
337
  // 28 px, arrowing up at the ceiling, closing a symmetrical block -- writes
265
338
  // nothing either, so the editor does not see an edit that was not one.
266
- if (ALL_SIDES.every((side) => next[side] === sides.value[side])) return
339
+ if (ALL_SIDES.every((side) => next[side] === values.value[side])) return
267
340
 
268
- sides.value = next
269
- model.value = serialize(next)
341
+ values.value = next
342
+ model.value = axis.value ? serializeAxis(next, axis.value, expanded.value) : serialize(next)
270
343
  }
271
344
 
272
345
  const write = (field: PaddingField, step: number) => {
273
- const next = { ...sides.value }
346
+ const next = { ...values.value }
274
347
  for (const side of field.targets) next[side] = step
275
348
 
276
349
  apply(next)
@@ -281,7 +354,7 @@ const onCommit = (field: PaddingField, px: number) => write(field, snap(px))
281
354
 
282
355
  /** One step along the list, so the arrow keys never stop on a missing token. */
283
356
  const onStep = (field: PaddingField, direction: 1 | -1) => {
284
- const index = EDITOR_PADDING_STEPS.findIndex(({ token }) => token === sides.value[field.source])
357
+ const index = EDITOR_PADDING_STEPS.findIndex(({ token }) => token === values.value[field.source])
285
358
  // No wrapping: the ends of the list are the ends of the range.
286
359
  const next = Math.min(Math.max(index + direction, 0), EDITOR_PADDING_STEPS.length - 1)
287
360
 
@@ -300,7 +373,13 @@ const onToggle = () => {
300
373
  expanded.value = !expanded.value
301
374
  if (expanded.value) return
302
375
 
303
- apply({ ...sides.value, bottom: sides.value.top, left: sides.value.right })
376
+ if (axis.value) {
377
+ // The collapsed field reads the first side, so the second follows it.
378
+ const [first, second] = AXES[axis.value].sides
379
+ return apply({ ...values.value, [second]: values.value[first] })
380
+ }
381
+
382
+ apply({ ...values.value, bottom: values.value.top, left: values.value.right })
304
383
  }
305
384
  </script>
306
385
 
@@ -313,9 +392,14 @@ const onToggle = () => {
313
392
  ones above them.
314
393
  -->
315
394
  <div class="grid grid-cols-[1fr_1fr_2.5rem] gap-2">
395
+ <!--
396
+ A two-sided block has one field when collapsed; it spans both field
397
+ columns so the toggle stays in the third.
398
+ -->
316
399
  <PaddingField
317
400
  v-for="field in primaryFields"
318
401
  :key="field.key"
402
+ :class="{ 'col-span-2': primaryFields.length === 1 }"
319
403
  :sides="field.targets"
320
404
  :label="field.label"
321
405
  :value="display(field.source)"
@@ -358,6 +442,7 @@ const onToggle = () => {
358
442
  `inert` keeps the two hidden fields out of the tab order meanwhile.
359
443
  -->
360
444
  <div
445
+ v-if="!axis"
361
446
  data-testid="editor-padding-secondary-row"
362
447
  :inert="secondaryInert"
363
448
  :class="[
@@ -44,6 +44,14 @@ export interface NexxtEditorTextAlignProps {
44
44
  * Copy is never translated inside the component.
45
45
  */
46
46
  messages?: EditorTextAlignMessages
47
+ /**
48
+ * The options to offer, as a subset of the four. They are always shown in
49
+ * the order of `EDITOR_TEXT_ALIGN_OPTIONS`, whatever order this list is in.
50
+ * Defaults to all four. A consumer that writes alignment as something other
51
+ * than `text-*` -- auto margins, say -- passes the three it can translate and
52
+ * maps the value itself.
53
+ */
54
+ options?: EditorTextAlignValue[]
47
55
  }
48
56
 
49
57
  const DEFAULT_MESSAGES = {
@@ -62,7 +70,11 @@ import type { NexxtToggleGroupOption } from '../../../molecules/ToggleGroup/Togg
62
70
 
63
71
  defineOptions({ name: 'NexxtEditorTextAlign' })
64
72
 
65
- const { label = 'Tekst uitlijning', messages } = defineProps<NexxtEditorTextAlignProps>()
73
+ const {
74
+ label = 'Tekst uitlijning',
75
+ messages,
76
+ options: offered,
77
+ } = defineProps<NexxtEditorTextAlignProps>()
66
78
 
67
79
  /** The selected text alignment, as the Tailwind class to apply. */
68
80
  const model = defineModel<EditorTextAlignValue>({ required: true })
@@ -87,8 +99,12 @@ const ICONS: Record<
87
99
  justify: 'align-justify',
88
100
  }
89
101
 
102
+ // Filtered from the full list rather than mapped from `offered`, so the order is
103
+ // always the canonical one and a duplicate in `offered` cannot double a button.
90
104
  const options = computed<NexxtToggleGroupOption[]>(() =>
91
- EDITOR_TEXT_ALIGN_OPTIONS.map(({ token }) => ({
105
+ EDITOR_TEXT_ALIGN_OPTIONS.filter(
106
+ ({ token }) => !offered || offered.includes(`text-${token}`),
107
+ ).map(({ token }) => ({
92
108
  value: `text-${token}`,
93
109
  icon: ICONS[token],
94
110
  ariaLabel: copy.value[token],
@@ -36,6 +36,22 @@ const props = withDefaults(defineProps<NexxtToggleGroupProps>(), {
36
36
 
37
37
  const model = defineModel<string>({ required: true })
38
38
 
39
+ defineSlots<{
40
+ /**
41
+ * Content of one option, replacing the default icon and label. Rendered
42
+ * twice per option -- once on the button and once in the clipped overlay
43
+ * that shows the active colour -- so it should draw in `currentColor` and
44
+ * carry no state or ids of its own. The button keeps its `ariaLabel` /
45
+ * `tooltip` naming, so content without text still needs `ariaLabel`.
46
+ */
47
+ option?: (props: {
48
+ /** The option being rendered. */
49
+ option: NexxtToggleGroupOption
50
+ /** Whether this option is the selected value. */
51
+ selected: boolean
52
+ }) => unknown
53
+ }>()
54
+
39
55
  const containerRef = useTemplateRef('containerRef')
40
56
  const buttonRefs = ref<HTMLButtonElement[]>([])
41
57
  const sliderStyle = ref({ width: '0px', transform: 'translateX(0px)' })
@@ -112,12 +128,14 @@ onMounted(async () => {
112
128
  :ref="(el) => (buttonRefs[index] = el as HTMLButtonElement)"
113
129
  type="button"
114
130
  :aria-label="option.ariaLabel ?? (option.label ? undefined : option.tooltip)"
115
- class="relative z-10 flex cursor-pointer items-center justify-center gap-1.5 rounded-lg px-4 py-1.5 whitespace-nowrap text-gray-500 hover:text-gray-700"
131
+ class="relative z-10 flex cursor-pointer items-center justify-center gap-1.5 rounded-lg px-4 py-1.5 whitespace-nowrap text-gray-700 hover:text-gray-900"
116
132
  :class="{ 'flex-1': equalWidth }"
117
133
  @click="model = option.value"
118
134
  >
119
- <NexxtIcon v-if="option.icon" :name="option.icon" />
120
- <span v-if="option.label" class="extra-small-semibold">{{ option.label }}</span>
135
+ <slot name="option" :option="option" :selected="option.value === model">
136
+ <NexxtIcon v-if="option.icon" :name="option.icon" />
137
+ <span v-if="option.label" class="extra-small-semibold">{{ option.label }}</span>
138
+ </slot>
121
139
  </button>
122
140
  <template #tooltip>{{ option.tooltip }}</template>
123
141
  </NexxtTooltip>
@@ -126,12 +144,14 @@ onMounted(async () => {
126
144
  :ref="(el) => (buttonRefs[index] = el as HTMLButtonElement)"
127
145
  type="button"
128
146
  :aria-label="option.ariaLabel ?? (option.label ? undefined : option.tooltip)"
129
- class="relative z-10 flex cursor-pointer items-center justify-center gap-1.5 rounded-lg px-4 py-1.5 whitespace-nowrap text-gray-500 hover:text-gray-700"
147
+ class="relative z-10 flex cursor-pointer items-center justify-center gap-1.5 rounded-lg px-4 py-1.5 whitespace-nowrap text-gray-700 hover:text-gray-900"
130
148
  :class="{ 'flex-1': equalWidth }"
131
149
  @click="model = option.value"
132
150
  >
133
- <NexxtIcon v-if="option.icon" :name="option.icon" />
134
- <span v-if="option.label" class="extra-small-semibold">{{ option.label }}</span>
151
+ <slot name="option" :option="option" :selected="option.value === model">
152
+ <NexxtIcon v-if="option.icon" :name="option.icon" />
153
+ <span v-if="option.label" class="extra-small-semibold">{{ option.label }}</span>
154
+ </slot>
135
155
  </button>
136
156
  </template>
137
157
 
@@ -150,8 +170,10 @@ onMounted(async () => {
150
170
  class="flex items-center justify-center gap-1.5 rounded-lg px-4 py-1.5 whitespace-nowrap"
151
171
  :class="[{ 'flex-1': equalWidth }, option.activeClass ?? 'text-cornflower-blue-600']"
152
172
  >
153
- <NexxtIcon v-if="option.icon" :name="option.icon" />
154
- <span v-if="option.label" class="extra-small-semibold">{{ option.label }}</span>
173
+ <slot name="option" :option="option" :selected="option.value === model">
174
+ <NexxtIcon v-if="option.icon" :name="option.icon" />
175
+ <span v-if="option.label" class="extra-small-semibold">{{ option.label }}</span>
176
+ </slot>
155
177
  </span>
156
178
  </div>
157
179
  </div>
@@ -74,10 +74,12 @@
74
74
  "NexxtVideoPlayer": "components/organisms/VideoPlayer/VideoPlayer.vue",
75
75
  "NexxtEditorDimensionField": "components/editor/atoms/EditorDimensionField/EditorDimensionField.vue",
76
76
  "NexxtEditorSettingsBlock": "components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue",
77
+ "NexxtEditorSettingsGroup": "components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.vue",
77
78
  "NexxtEditorAlignItems": "components/editor/molecules/EditorAlignItems/EditorAlignItems.vue",
78
79
  "NexxtEditorBackgroundSize": "components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue",
79
80
  "NexxtEditorBorder": "components/editor/molecules/EditorBorder/EditorBorder.vue",
80
81
  "NexxtEditorColor": "components/editor/molecules/EditorColor/EditorColor.vue",
82
+ "NexxtEditorDashPattern": "components/editor/molecules/EditorDashPattern/EditorDashPattern.vue",
81
83
  "NexxtEditorFlexDirection": "components/editor/molecules/EditorFlexDirection/EditorFlexDirection.vue",
82
84
  "NexxtEditorFontSize": "components/editor/molecules/EditorFontSize/EditorFontSize.vue",
83
85
  "NexxtEditorGap": "components/editor/molecules/EditorGap/EditorGap.vue",
@@ -85,6 +87,7 @@
85
87
  "NexxtEditorJustifyContent": "components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue",
86
88
  "NexxtEditorLetterSpacing": "components/editor/molecules/EditorLetterSpacing/EditorLetterSpacing.vue",
87
89
  "NexxtEditorLineHeight": "components/editor/molecules/EditorLineHeight/EditorLineHeight.vue",
90
+ "NexxtEditorLineStyle": "components/editor/molecules/EditorLineStyle/EditorLineStyle.vue",
88
91
  "NexxtEditorMaxWidth": "components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue",
89
92
  "NexxtEditorPadding": "components/editor/molecules/EditorPadding/EditorPadding.vue",
90
93
  "NexxtEditorRadius": "components/editor/molecules/EditorRadius/EditorRadius.vue",
package/src/index.ts CHANGED
@@ -138,6 +138,25 @@ export type {
138
138
  EditorSizeValue,
139
139
  } from './components/editor/molecules/EditorSize/EditorSize.vue'
140
140
  export type { EditorSizeLimitMessages } from './components/editor/molecules/EditorSizeLimit/EditorSizeLimit.vue'
141
+ export {
142
+ EDITOR_LINE_MIN_THICKNESS,
143
+ EDITOR_LINE_STYLE_OPTIONS,
144
+ EDITOR_LINE_THICKNESS_STEPS,
145
+ } from './components/editor/molecules/EditorLineStyle/EditorLineStyle.vue'
146
+ export type {
147
+ EditorLineStyleMessages,
148
+ EditorLineStyleValue,
149
+ EditorLineThickness,
150
+ NexxtEditorLineStyleProps,
151
+ } from './components/editor/molecules/EditorLineStyle/EditorLineStyle.vue'
152
+ export type { NexxtEditorSettingsGroupProps } from './components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.vue'
153
+ export type { EditorSettingsHeadingLevel } from './components/editor/atoms/EditorSettingsGroup/editorSettingsGroupContext'
154
+ export { EDITOR_DASH_PATTERN_LIMITS } from './components/editor/molecules/EditorDashPattern/EditorDashPattern.vue'
155
+ export type {
156
+ EditorDashPatternMessages,
157
+ EditorDashPatternValue,
158
+ NexxtEditorDashPatternProps,
159
+ } from './components/editor/molecules/EditorDashPattern/EditorDashPattern.vue'
141
160
  export type {
142
161
  NexxtTemplateCardContentFeature,
143
162
  NexxtTemplateCardProps,
@@ -165,6 +184,7 @@ export { default as NexxtEditorAlignItems } from './components/editor/molecules/
165
184
  export { default as NexxtEditorBackgroundSize } from './components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue'
166
185
  export { default as NexxtEditorBorder } from './components/editor/molecules/EditorBorder/EditorBorder.vue'
167
186
  export { default as NexxtEditorColor } from './components/editor/molecules/EditorColor/EditorColor.vue'
187
+ export { default as NexxtEditorDashPattern } from './components/editor/molecules/EditorDashPattern/EditorDashPattern.vue'
168
188
  export { default as NexxtEditorDimensionField } from './components/editor/atoms/EditorDimensionField/EditorDimensionField.vue'
169
189
  export { default as NexxtEditorFlexDirection } from './components/editor/molecules/EditorFlexDirection/EditorFlexDirection.vue'
170
190
  export { default as NexxtEditorFontSize } from './components/editor/molecules/EditorFontSize/EditorFontSize.vue'
@@ -173,10 +193,12 @@ export { default as NexxtEditorHeadingType } from './components/editor/molecules
173
193
  export { default as NexxtEditorJustifyContent } from './components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue'
174
194
  export { default as NexxtEditorLetterSpacing } from './components/editor/molecules/EditorLetterSpacing/EditorLetterSpacing.vue'
175
195
  export { default as NexxtEditorLineHeight } from './components/editor/molecules/EditorLineHeight/EditorLineHeight.vue'
196
+ export { default as NexxtEditorLineStyle } from './components/editor/molecules/EditorLineStyle/EditorLineStyle.vue'
176
197
  export { default as NexxtEditorMaxWidth } from './components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue'
177
198
  export { default as NexxtEditorPadding } from './components/editor/molecules/EditorPadding/EditorPadding.vue'
178
199
  export { default as NexxtEditorRadius } from './components/editor/molecules/EditorRadius/EditorRadius.vue'
179
200
  export { default as NexxtEditorSettingsBlock } from './components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue'
201
+ export { default as NexxtEditorSettingsGroup } from './components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.vue'
180
202
  export { default as NexxtEditorSize } from './components/editor/molecules/EditorSize/EditorSize.vue'
181
203
  export { default as NexxtEditorSizeLimit } from './components/editor/molecules/EditorSizeLimit/EditorSizeLimit.vue'
182
204
  export { default as NexxtEditorTextAlign } from './components/editor/molecules/EditorTextAlign/EditorTextAlign.vue'
@@ -55,4 +55,10 @@
55
55
  spot to another. */
56
56
  --ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1);
57
57
  --ease-in-out-strong: cubic-bezier(0.77, 0, 0.175, 1);
58
+
59
+ /* The one colour of every divider in the editor settings panel, as
60
+ `border-editor-divider`. Drawn by NexxtEditorSettingsGroup. Same name as
61
+ the copy in the app theme (app/assets/css/main.css), so the app's own
62
+ dividers keep resolving; the app drops its copy once it adopts this. */
63
+ --color-editor-divider: var(--color-gray-200);
58
64
  }