@nexxtmove/ui 1.45.0 → 1.47.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 (32) 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/EditorShadow/EditorShadow.vue.d.ts +72 -0
  10. package/dist/components/editor/molecules/EditorTextAlign/EditorTextAlign.vue.d.ts +8 -0
  11. package/dist/components/molecules/ToggleGroup/ToggleGroup.vue.d.ts +22 -1
  12. package/dist/index.d.ts +12 -0
  13. package/dist/index.js +2436 -1972
  14. package/dist/nuxt.js +4 -0
  15. package/package.json +3 -3
  16. package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue +36 -5
  17. package/src/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.vue +228 -0
  18. package/src/components/editor/atoms/EditorSettingsGroup/editorSettingsGroupContext.ts +27 -0
  19. package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.vue +17 -3
  20. package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue +1 -1
  21. package/src/components/editor/molecules/EditorDashPattern/EditorDashPattern.vue +139 -0
  22. package/src/components/editor/molecules/EditorFlexDirection/EditorFlexDirection.vue +1 -1
  23. package/src/components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue +17 -3
  24. package/src/components/editor/molecules/EditorLineStyle/EditorLineStyle.vue +230 -0
  25. package/src/components/editor/molecules/EditorPadding/EditorPadding.vue +102 -17
  26. package/src/components/editor/molecules/EditorShadow/EditorShadow.vue +106 -0
  27. package/src/components/editor/molecules/EditorSize/EditorSize.vue +1 -1
  28. package/src/components/editor/molecules/EditorTextAlign/EditorTextAlign.vue +19 -3
  29. package/src/components/molecules/ToggleGroup/ToggleGroup.vue +105 -11
  30. package/src/components.json +4 -0
  31. package/src/index.ts +29 -0
  32. package/tailwind/nexxtmove.tailwind.custom.css +9 -4
package/dist/nuxt.js CHANGED
@@ -77,10 +77,12 @@ var s = {
77
77
  NexxtVideoPlayer: "components/organisms/VideoPlayer/VideoPlayer.vue",
78
78
  NexxtEditorDimensionField: "components/editor/atoms/EditorDimensionField/EditorDimensionField.vue",
79
79
  NexxtEditorSettingsBlock: "components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue",
80
+ NexxtEditorSettingsGroup: "components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.vue",
80
81
  NexxtEditorAlignItems: "components/editor/molecules/EditorAlignItems/EditorAlignItems.vue",
81
82
  NexxtEditorBackgroundSize: "components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue",
82
83
  NexxtEditorBorder: "components/editor/molecules/EditorBorder/EditorBorder.vue",
83
84
  NexxtEditorColor: "components/editor/molecules/EditorColor/EditorColor.vue",
85
+ NexxtEditorDashPattern: "components/editor/molecules/EditorDashPattern/EditorDashPattern.vue",
84
86
  NexxtEditorFlexDirection: "components/editor/molecules/EditorFlexDirection/EditorFlexDirection.vue",
85
87
  NexxtEditorFontSize: "components/editor/molecules/EditorFontSize/EditorFontSize.vue",
86
88
  NexxtEditorGap: "components/editor/molecules/EditorGap/EditorGap.vue",
@@ -88,9 +90,11 @@ var s = {
88
90
  NexxtEditorJustifyContent: "components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue",
89
91
  NexxtEditorLetterSpacing: "components/editor/molecules/EditorLetterSpacing/EditorLetterSpacing.vue",
90
92
  NexxtEditorLineHeight: "components/editor/molecules/EditorLineHeight/EditorLineHeight.vue",
93
+ NexxtEditorLineStyle: "components/editor/molecules/EditorLineStyle/EditorLineStyle.vue",
91
94
  NexxtEditorMaxWidth: "components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue",
92
95
  NexxtEditorPadding: "components/editor/molecules/EditorPadding/EditorPadding.vue",
93
96
  NexxtEditorRadius: "components/editor/molecules/EditorRadius/EditorRadius.vue",
97
+ NexxtEditorShadow: "components/editor/molecules/EditorShadow/EditorShadow.vue",
94
98
  NexxtEditorSize: "components/editor/molecules/EditorSize/EditorSize.vue",
95
99
  NexxtEditorSizeLimit: "components/editor/molecules/EditorSizeLimit/EditorSizeLimit.vue",
96
100
  NexxtEditorTextAlign: "components/editor/molecules/EditorTextAlign/EditorTextAlign.vue"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nexxtmove/ui",
3
3
  "type": "module",
4
- "version": "1.45.0",
4
+ "version": "1.47.0",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.ts",
@@ -50,7 +50,7 @@
50
50
  "@vitejs/plugin-vue": "^6.0.8",
51
51
  "@vitest/browser": "^4.1.11",
52
52
  "@vitest/browser-playwright": "^4.1.11",
53
- "@vitest/coverage-v8": "^4.1.10",
53
+ "@vitest/coverage-v8": "^4.1.11",
54
54
  "@vue/eslint-config-typescript": "^14.9.0",
55
55
  "@vue/test-utils": "^2.4.11",
56
56
  "@vue/tsconfig": "^0.9.1",
@@ -74,7 +74,7 @@
74
74
  "typescript-eslint": "^8.67.0",
75
75
  "vite": "^8.2.1",
76
76
  "vite-plugin-dts": "^5.0.3",
77
- "vitest": "^4.1.10"
77
+ "vitest": "^4.1.11"
78
78
  },
79
79
  "engines": {
80
80
  "node": ">=22.18.0"
@@ -1,6 +1,7 @@
1
1
  <script lang="ts" setup>
2
- import { computed } from 'vue'
2
+ import { computed, inject } from 'vue'
3
3
  import Switch from '../../../atoms/Switch/Switch.vue'
4
+ import { EDITOR_SETTINGS_GROUP_KEY } from '../EditorSettingsGroup/editorSettingsGroupContext'
4
5
 
5
6
  interface NexxtEditorSettingsBlockProps {
6
7
  /** Header text of the block. Wraps over multiple lines when it is too long. */
@@ -14,7 +15,8 @@ interface NexxtEditorSettingsBlockProps {
14
15
 
15
16
  /**
16
17
  * One labelled setting in the editor sidebar, optionally collapsible via a
17
- * switch. The block draws no separator of its own: the consumer groups and
18
+ * switch. The block draws no separator of its own: inside a
19
+ * NexxtEditorSettingsGroup the group draws it, and outside one the consumer
18
20
  * divides blocks on the wrapper, e.g. `divide-y divide-gray-200`.
19
21
  */
20
22
  defineOptions({ name: 'NexxtEditorSettingsBlock' })
@@ -34,10 +36,37 @@ const open = computed(() => !switchable || model.value)
34
36
  // `inert="false"`, which a browser still treats as inert. `undefined` is the
35
37
  // only value that removes the attribute again.
36
38
  const inert = computed(() => (open.value ? undefined : true))
39
+
40
+ /** Set when the block sits in a NexxtEditorSettingsGroup; `null` outside one. */
41
+ const group = inject(EDITOR_SETTINGS_GROUP_KEY, null)
42
+
43
+ /**
44
+ * In a group all space sits above the block, inside its own box: a collapsed
45
+ * reveal around it then takes that space along, and the group owns the bottom.
46
+ * Outside a group the block keeps the `py-4` other consumers rely on. Every
47
+ * block ends 4px below its control (the content wrapper's `pb-1`), so the
48
+ * group's `pb-5` puts the next line 24px under the last visible control.
49
+ */
50
+ const rootClass = computed(() => (group ? 'pt-4' : 'py-4'))
51
+
52
+ /**
53
+ * The only setting of a group without a label of its own carries the group's
54
+ * weight, so it reads as heavy as a labelled group next to it.
55
+ */
56
+ const promoted = computed(() => group?.value.promoted ?? false)
57
+
58
+ const labelTypography = computed(() => (promoted.value ? 'small-semibold' : 'small-normal'))
59
+
60
+ /**
61
+ * A promoted label is a heading, so heading navigation finds it next to the
62
+ * labelled groups. A switchable label stays the switch's <label>: a heading
63
+ * cannot be one.
64
+ */
65
+ const labelTag = computed(() => (promoted.value && group ? `h${group.value.headingLevel}` : 'p'))
37
66
  </script>
38
67
 
39
68
  <template>
40
- <div class="py-4">
69
+ <div :class="rootClass">
41
70
  <!--
42
71
  Switch.vue sets `inheritAttrs: false` and binds `$attrs` on its sr-only
43
72
  input, so a class on <Switch> never reaches its root <label>. The child
@@ -49,9 +78,11 @@ const inert = computed(() => (open.value ? undefined : true))
49
78
  -->
50
79
  <div class="flex items-center [&>label]:flex [&>label]:w-full [&>label]:gap-3">
51
80
  <Switch v-if="switchable" v-model="model" size="sm" color="blue" label-position="before">
52
- <span class="grow text-left small-normal text-gray-900">{{ label }}</span>
81
+ <span :class="['grow text-left', labelTypography, 'text-gray-900']">{{ label }}</span>
53
82
  </Switch>
54
- <p v-else class="small-normal text-gray-900">{{ label }}</p>
83
+ <component :is="labelTag" v-else :class="[labelTypography, 'text-gray-900']">
84
+ {{ label }}
85
+ </component>
55
86
  </div>
56
87
 
57
88
  <div
@@ -0,0 +1,228 @@
1
+ <script lang="ts">
2
+ import type { EditorSettingsHeadingLevel } from './editorSettingsGroupContext'
3
+
4
+ export interface NexxtEditorSettingsGroupProps {
5
+ /**
6
+ * Heading of the group. Omit it for a section that holds exactly one
7
+ * setting: that setting's own label is then promoted to the group's
8
+ * typography and heading level, so it does not say the same thing twice.
9
+ */
10
+ label?: string
11
+ /** Heading level of the label. Defaults to 3: the settings panel's own heading is an h2. */
12
+ headingLevel?: EditorSettingsHeadingLevel
13
+ /**
14
+ * Let the user fold the group away with a button in its heading. Only takes
15
+ * effect together with `label`: a group without a heading has nothing to
16
+ * click, so it stays an ordinary, always-open group and warns in
17
+ * development. The open state is `v-model:open`. Defaults to `false`.
18
+ */
19
+ collapsible?: boolean
20
+ }
21
+
22
+ /**
23
+ * Per target state, so a reversal mid-way eases from where it is.
24
+ *
25
+ * Opening uses `ease-out-strong`: the content arrives fast and settles. Closing
26
+ * uses `ease-in-out-strong` over the same 200ms. A strong ease-out would take
27
+ * most of a tall group (~320px) away in the first frames and read as a cut;
28
+ * ease-in-out starts gently, so the closing is seen. Height, the root's bottom
29
+ * padding and the chevron share duration and curve in each direction, so they
30
+ * stay in step frame for frame.
31
+ *
32
+ * The fade on closing is 150ms on the softer `ease-in-out`, so the content
33
+ * stays readable while it folds and is gone before the clip cuts it. Under
34
+ * reduced motion height, padding and chevron jump; the fade stays.
35
+ */
36
+ const MOTION = {
37
+ root: {
38
+ base: 'transition-[padding-bottom] motion-reduce:transition-none',
39
+ open: 'pb-5 duration-200 ease-out-strong',
40
+ closed: 'pb-6 duration-200 ease-in-out-strong',
41
+ },
42
+ rows: {
43
+ base: 'grid transition-[grid-template-rows] motion-reduce:transition-none',
44
+ open: 'grid-rows-[1fr] duration-200 ease-out-strong',
45
+ closed: 'grid-rows-[0fr] duration-200 ease-in-out-strong',
46
+ },
47
+ clip: {
48
+ base: '-mx-1 -mb-1 min-h-0 overflow-hidden transition-opacity',
49
+ open: 'opacity-100 duration-200 ease-out-strong',
50
+ closed: 'opacity-0 duration-150 ease-in-out',
51
+ },
52
+ chevron: {
53
+ base: 'flex size-5 shrink-0 items-center justify-center transition-transform motion-reduce:transition-none',
54
+ open: 'rotate-0 duration-200 ease-out-strong',
55
+ closed: 'rotate-180 duration-200 ease-in-out-strong',
56
+ },
57
+ } as const
58
+ </script>
59
+
60
+ <script lang="ts" setup>
61
+ import { computed, inject, provide, useId, useTemplateRef, watch } from 'vue'
62
+ import NexxtIcon from '../../../atoms/Icon/Icon.vue'
63
+ import { EDITOR_SETTINGS_GROUP_KEY } from './editorSettingsGroupContext'
64
+
65
+ /**
66
+ * A section of an editor settings panel: the settings that are about the same
67
+ * thing. It draws the divider above itself and owns the vertical space, so the
68
+ * settings inside need neither. Optionally collapsible from its heading.
69
+ */
70
+ defineOptions({ name: 'NexxtEditorSettingsGroup' })
71
+
72
+ const {
73
+ label,
74
+ headingLevel = 3,
75
+ collapsible = false,
76
+ } = defineProps<NexxtEditorSettingsGroupProps>()
77
+
78
+ /**
79
+ * Whether a collapsible group is open. Ignored unless the group is both
80
+ * `collapsible` and labelled; the parent owns the state.
81
+ */
82
+ const open = defineModel<boolean>('open', { default: true })
83
+
84
+ defineSlots<{
85
+ /** The settings: EditorSettingsBlocks, possibly wrapped in a reveal or a mobile marker. */
86
+ default?: () => unknown
87
+ }>()
88
+
89
+ // One heading level per panel is the whole point: a group in a group would be a
90
+ // second level in 312 px of sidebar. It still renders, so a mistake shows up in
91
+ // the console during development rather than as a blank panel. `process.env`
92
+ // rather than `import.meta.env`: the library build bakes the latter in as
93
+ // production, while the consumer's bundler replaces the former for its own mode.
94
+ if (inject(EDITOR_SETTINGS_GROUP_KEY, null) && process.env.NODE_ENV !== 'production') {
95
+ console.warn('NexxtEditorSettingsGroup cannot be nested')
96
+ }
97
+
98
+ provide(
99
+ EDITOR_SETTINGS_GROUP_KEY,
100
+ computed(() => ({ grouped: true, promoted: !label, headingLevel })),
101
+ )
102
+
103
+ if (collapsible && !label && process.env.NODE_ENV !== 'production') {
104
+ console.warn('NexxtEditorSettingsGroup: collapsible needs a label; the group stays open')
105
+ }
106
+
107
+ const headingId = useId()
108
+
109
+ const heading = computed(() => `h${headingLevel}`)
110
+
111
+ /**
112
+ * Collapsing needs a heading to put the button in. The heading stays a
113
+ * heading, with one button across the whole row: `-my-0.5 py-0.5` makes it
114
+ * 24px tall without changing the 20px layout height and `-mx-1 px-1` gives the
115
+ * focus ring room, so open it is pixel-equal to a group that does not collapse.
116
+ * No `focus:outline-none`: in Tailwind v4 it sets `--tw-outline-style: none`,
117
+ * which `focus-visible:outline-2` then picks up, so the ring would never show.
118
+ * A button shows no outline on a mouse click anyway. The group is named
119
+ * (`group/toggle`) so a `.group` around the panel cannot light the chevron.
120
+ * The heading is `flex` so those negative margins stay on the button instead
121
+ * of collapsing through the heading and shifting it by 2px.
122
+ *
123
+ * The content stays mounted while closed, so the settings keep their local
124
+ * state and `aria-controls` always points at an element. The first block's
125
+ * `pt-4` sits inside the clip box and folds away with it, so the content
126
+ * wrapper is exactly 0px closed. Open, the last block's 4px of focus-ring room
127
+ * plus the root's `pb-5` make 24px under the last control; closed, that room
128
+ * has folded away too, so the root goes to `pb-6` and the title also ends 24px
129
+ * above the next line. The root padding transitions with the rows, so the
130
+ * height moves in one motion instead of jumping 4px. The clip box reaches 4px past each side (`-mx-1` / `px-1`) and 4px
131
+ * below (`-mb-1` / `pb-1`) without taking layout space, so the focus ring of a
132
+ * full-width or last control is not cut off.
133
+ *
134
+ * None of this is written as template comments: a group that does not
135
+ * collapse has to render exactly the markup it did before.
136
+ */
137
+ const active = computed(() => collapsible && !!label)
138
+
139
+ /**
140
+ * Derived from the heading id rather than a second `useId`, so a group that
141
+ * does not collapse renders and consumes exactly the ids it did before.
142
+ */
143
+ const contentId = computed(() => `${headingId}-content`)
144
+
145
+ const expanded = computed(() => !active.value || open.value)
146
+
147
+ // `undefined`, never `false`: `:inert="false"` renders `inert="false"`, which a
148
+ // browser still treats as inert. Same as EditorSettingsBlock.
149
+ const inert = computed(() => (expanded.value ? undefined : true))
150
+
151
+ const state = computed(() => (expanded.value ? 'open' : 'closed'))
152
+
153
+ // A group that does not collapse keeps exactly the classes it had before.
154
+ const rootClass = computed(() =>
155
+ active.value
156
+ ? ['border-t border-editor-divider pt-6', MOTION.root.base, MOTION.root[state.value]]
157
+ : ['border-t border-editor-divider pb-5', label ? 'pt-6' : 'pt-2'],
158
+ )
159
+
160
+ const toggleEl = useTemplateRef<HTMLButtonElement>('toggle')
161
+ const contentEl = useTemplateRef<HTMLElement>('content')
162
+
163
+ // Closed from outside while focus is inside: `inert` would drop it on <body>.
164
+ // Runs before the DOM update, while the content can still hold focus.
165
+ watch(open, (isOpen) => {
166
+ if (isOpen || !active.value) return
167
+ if (contentEl.value?.contains(document.activeElement)) toggleEl.value?.focus()
168
+ })
169
+ </script>
170
+
171
+ <template>
172
+ <div
173
+ :class="rootClass"
174
+ :role="label ? 'group' : undefined"
175
+ :aria-labelledby="label ? headingId : undefined"
176
+ >
177
+ <!--
178
+ The line sits on top, always, the first group included: it separates the
179
+ group from whatever comes before it, so the last group has none below.
180
+ 24px between groups: line to heading, and last control to the next line
181
+ (the last block's 4px of focus-ring room plus `pb-5`); 16px inside one.
182
+ Without a label the top is `pt-2`: the one setting inside brings its own
183
+ `pt-4`, so its promoted label also lands 24px under the line.
184
+ -->
185
+ <component
186
+ :is="heading"
187
+ v-if="active"
188
+ :id="headingId"
189
+ class="flex small-semibold text-gray-900"
190
+ >
191
+ <button
192
+ ref="toggle"
193
+ type="button"
194
+ :aria-expanded="open"
195
+ :aria-controls="contentId"
196
+ class="group/toggle -mx-1 -my-0.5 flex w-[calc(100%+0.5rem)] cursor-pointer items-center justify-between gap-2 rounded-md px-1 py-0.5 text-left focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-cornflower-blue-600"
197
+ @click="open = !open"
198
+ >
199
+ {{ label }}
200
+ <span aria-hidden="true" :class="[MOTION.chevron.base, MOTION.chevron[state]]">
201
+ <NexxtIcon
202
+ name="chevron-down"
203
+ class="extra-small-normal text-gray-700 transition-colors duration-150 group-hover/toggle:text-gray-900"
204
+ />
205
+ </span>
206
+ </button>
207
+ </component>
208
+ <component :is="heading" v-else-if="label" :id="headingId" class="small-semibold text-gray-900">
209
+ {{ label }}
210
+ </component>
211
+
212
+ <div
213
+ v-if="active"
214
+ :id="contentId"
215
+ ref="content"
216
+ data-testid="editor-settings-group-content"
217
+ :inert="inert"
218
+ :class="[MOTION.rows.base, MOTION.rows[state]]"
219
+ >
220
+ <div :class="[MOTION.clip.base, MOTION.clip[state]]">
221
+ <div class="px-1 pb-1">
222
+ <slot />
223
+ </div>
224
+ </div>
225
+ </div>
226
+ <slot v-else />
227
+ </div>
228
+ </template>
@@ -0,0 +1,27 @@
1
+ import type { ComputedRef, InjectionKey } from 'vue'
2
+
3
+ /** The heading levels a group label can take. The settings panel's own heading is an h2. */
4
+ export type EditorSettingsHeadingLevel = 2 | 3 | 4 | 5 | 6
5
+
6
+ /**
7
+ * What a NexxtEditorSettingsGroup tells the settings blocks inside it. Library
8
+ * internal: the group and the block are the only two that read or write it.
9
+ */
10
+ export interface EditorSettingsGroupContext {
11
+ /** Always `true`: the block is inside a group, so the group owns the line and the bottom space. */
12
+ grouped: true
13
+ /**
14
+ * The group has no label of its own, so the label of the one setting in it
15
+ * is promoted to the group's typography and heading level.
16
+ */
17
+ promoted: boolean
18
+ /** Heading level of the group label, and of a promoted setting label. */
19
+ headingLevel: EditorSettingsHeadingLevel
20
+ }
21
+
22
+ /**
23
+ * Provided as a computed, so a label or heading level that changes after mount
24
+ * reaches the blocks without them having to remount.
25
+ */
26
+ export const EDITOR_SETTINGS_GROUP_KEY: InjectionKey<ComputedRef<EditorSettingsGroupContext>> =
27
+ Symbol('NexxtEditorSettingsGroup')
@@ -44,6 +44,12 @@ export interface NexxtEditorAlignItemsProps {
44
44
  * Copy is never translated inside the component.
45
45
  */
46
46
  messages?: EditorAlignItemsMessages
47
+ /**
48
+ * The options to offer, as a subset of the four. They are always shown in
49
+ * the order of `EDITOR_ALIGN_ITEMS_OPTIONS`, whatever order this list is in;
50
+ * values not on that list are ignored. Defaults to all four.
51
+ */
52
+ options?: EditorAlignItemsValue[]
47
53
  }
48
54
 
49
55
  const DEFAULT_MESSAGES = {
@@ -62,7 +68,11 @@ import type { NexxtToggleGroupOption } from '../../../molecules/ToggleGroup/Togg
62
68
 
63
69
  defineOptions({ name: 'NexxtEditorAlignItems' })
64
70
 
65
- const { label = 'Uitlijning items', messages } = defineProps<NexxtEditorAlignItemsProps>()
71
+ const {
72
+ label = 'Uitlijning items',
73
+ messages,
74
+ options: offered,
75
+ } = defineProps<NexxtEditorAlignItemsProps>()
66
76
 
67
77
  /** The selected alignment, as the Tailwind class to apply. */
68
78
  const model = defineModel<EditorAlignItemsValue>({ required: true })
@@ -91,8 +101,12 @@ const ICONS: Record<
91
101
  stretch: 'arrows-up-down',
92
102
  }
93
103
 
104
+ // Filtered from the full list rather than mapped from `offered`, so the order is
105
+ // always the canonical one and a duplicate in `offered` cannot double a button.
94
106
  const options = computed<NexxtToggleGroupOption[]>(() =>
95
- EDITOR_ALIGN_ITEMS_OPTIONS.map(({ token }) => ({
107
+ EDITOR_ALIGN_ITEMS_OPTIONS.filter(
108
+ ({ token }) => !offered || offered.includes(`items-${token}`),
109
+ ).map(({ token }) => ({
96
110
  value: `items-${token}`,
97
111
  icon: ICONS[token],
98
112
  ariaLabel: copy.value[token],
@@ -113,6 +127,6 @@ const selected = computed({
113
127
 
114
128
  <template>
115
129
  <NexxtEditorSettingsBlock :label="label">
116
- <NexxtToggleGroup v-model="selected" :options="options" />
130
+ <NexxtToggleGroup v-model="selected" :options="options" :aria-label="label" />
117
131
  </NexxtEditorSettingsBlock>
118
132
  </template>
@@ -105,6 +105,6 @@ const selected = computed({
105
105
 
106
106
  <template>
107
107
  <NexxtEditorSettingsBlock :label="label">
108
- <NexxtToggleGroup v-model="selected" :options="options" />
108
+ <NexxtToggleGroup v-model="selected" :options="options" :aria-label="label" />
109
109
  </NexxtEditorSettingsBlock>
110
110
  </template>
@@ -0,0 +1,139 @@
1
+ <script lang="ts">
2
+ import type {
3
+ EditorDimensionUnit,
4
+ EditorDimensionValue,
5
+ } from '../../atoms/EditorDimensionField/EditorDimensionField.vue'
6
+
7
+ /**
8
+ * A dash pattern in whole px: the length of one dash and the space between two.
9
+ * Numbers rather than classes, the same as `EditorDimensionValue`: the consumer
10
+ * writes them into CSS variables.
11
+ */
12
+ export interface EditorDashPatternValue {
13
+ dash: number
14
+ gap: number
15
+ }
16
+
17
+ /**
18
+ * The range each value is clamped to, in px. Exported so the consumer's class
19
+ * matcher knows the same bounds.
20
+ */
21
+ export const EDITOR_DASH_PATTERN_LIMITS = {
22
+ dash: { min: 1, max: 64 },
23
+ gap: { min: 1, max: 64 },
24
+ } as const
25
+
26
+ /** Overridable copy of the two fields. Every key is optional. */
27
+ export interface EditorDashPatternMessages {
28
+ /** Caption and accessible name of the dash length field. Defaults to `'Lengte'`. */
29
+ dash?: string
30
+ /** Caption and accessible name of the gap field. Defaults to `'Tussenruimte'`. */
31
+ gap?: string
32
+ /** How the px unit is written in both fields. Defaults to `'px'`. */
33
+ unit?: string
34
+ }
35
+
36
+ export interface NexxtEditorDashPatternProps {
37
+ /**
38
+ * Header text of the settings block. Defaults to the Dutch `'Streep'`; the
39
+ * library has no i18n layer, so a consumer in another language passes its
40
+ * own copy in.
41
+ */
42
+ label?: string
43
+ /**
44
+ * Captions of the fields and the unit. Defaults are Dutch and are merged per
45
+ * key, so overriding one keeps the defaults of the others. Copy is never
46
+ * translated inside the component.
47
+ */
48
+ messages?: EditorDashPatternMessages
49
+ }
50
+
51
+ const DEFAULT_MESSAGES = {
52
+ dash: 'Lengte',
53
+ gap: 'Tussenruimte',
54
+ unit: 'px',
55
+ } satisfies Required<EditorDashPatternMessages>
56
+
57
+ type DashPatternPart = keyof EditorDashPatternValue
58
+
59
+ /** Both values are whole px; there is no other unit to pick. */
60
+ const UNITS: EditorDimensionUnit[] = ['px']
61
+ </script>
62
+
63
+ <script lang="ts" setup>
64
+ import { computed } from 'vue'
65
+ import NexxtEditorSettingsBlock from '../../atoms/EditorSettingsBlock/EditorSettingsBlock.vue'
66
+ import NexxtEditorDimensionField from '../../atoms/EditorDimensionField/EditorDimensionField.vue'
67
+
68
+ defineOptions({ name: 'NexxtEditorDashPattern' })
69
+
70
+ const { label = 'Streep', messages } = defineProps<NexxtEditorDashPatternProps>()
71
+
72
+ /** The dash pattern, in whole px. */
73
+ const model = defineModel<EditorDashPatternValue>({ required: true })
74
+
75
+ const emit = defineEmits<{
76
+ /**
77
+ * One edit in either field is finished -- passed on from the field: a typed
78
+ * value confirmed on blur or Enter, or an arrow key let go. The model has
79
+ * already been written by then, so a consumer that folds writes into one
80
+ * undo step closes it here.
81
+ */
82
+ commit: []
83
+ }>()
84
+
85
+ const copy = computed(() => ({ ...DEFAULT_MESSAGES, ...messages }))
86
+
87
+ /**
88
+ * One field's view of the model. The field is never `optional`, so it never
89
+ * writes `null` -- an emptied field puts the previous value back -- and the
90
+ * guard is only there for the type.
91
+ */
92
+ const part = (key: DashPatternPart) =>
93
+ computed({
94
+ get: (): EditorDimensionValue | null => ({ value: model.value[key], unit: 'px' }),
95
+ set: (next: EditorDimensionValue | null) => {
96
+ if (next) model.value = { ...model.value, [key]: next.value }
97
+ },
98
+ })
99
+
100
+ const dash = part('dash')
101
+ const gap = part('gap')
102
+
103
+ const fieldOf = (key: DashPatternPart, model: typeof dash) => ({
104
+ key,
105
+ model,
106
+ label: copy.value[key],
107
+ min: { px: EDITOR_DASH_PATTERN_LIMITS[key].min },
108
+ max: { px: EDITOR_DASH_PATTERN_LIMITS[key].max },
109
+ })
110
+
111
+ const fields = computed(() => [fieldOf('dash', dash), fieldOf('gap', gap)])
112
+
113
+ const fieldMessages = computed(() => ({ px: copy.value.unit }))
114
+ </script>
115
+
116
+ <template>
117
+ <NexxtEditorSettingsBlock :label="label">
118
+ <div class="grid grid-cols-2 gap-2">
119
+ <!--
120
+ Each field gets a visible caption: the inputs have no label of their
121
+ own, and two px fields side by side cannot be told apart otherwise. The
122
+ caption is repeated as the field's accessible name, with the unit
123
+ appended by the field itself.
124
+ -->
125
+ <div v-for="field in fields" :key="field.key" class="flex min-w-0 flex-col gap-1">
126
+ <span class="small-normal text-gray-700" aria-hidden="true">{{ field.label }}</span>
127
+ <NexxtEditorDimensionField
128
+ v-model="field.model.value"
129
+ :label="field.label"
130
+ :units="UNITS"
131
+ :min="field.min"
132
+ :max="field.max"
133
+ :messages="fieldMessages"
134
+ @commit="emit('commit')"
135
+ />
136
+ </div>
137
+ </div>
138
+ </NexxtEditorSettingsBlock>
139
+ </template>
@@ -103,6 +103,6 @@ const selected = computed({
103
103
 
104
104
  <template>
105
105
  <NexxtEditorSettingsBlock :label="label">
106
- <NexxtToggleGroup v-model="selected" :options="options" />
106
+ <NexxtToggleGroup v-model="selected" :options="options" :aria-label="label" />
107
107
  </NexxtEditorSettingsBlock>
108
108
  </template>
@@ -45,6 +45,12 @@ export interface NexxtEditorJustifyContentProps {
45
45
  * Copy is never translated inside the component.
46
46
  */
47
47
  messages?: EditorJustifyContentMessages
48
+ /**
49
+ * The options to offer, as a subset of the four. They are always shown in
50
+ * the order of `EDITOR_JUSTIFY_CONTENT_OPTIONS`, whatever order this list is
51
+ * in. Defaults to all four.
52
+ */
53
+ options?: EditorJustifyContentValue[]
48
54
  }
49
55
 
50
56
  const DEFAULT_MESSAGES = {
@@ -63,7 +69,11 @@ import type { NexxtToggleGroupOption } from '../../../molecules/ToggleGroup/Togg
63
69
 
64
70
  defineOptions({ name: 'NexxtEditorJustifyContent' })
65
71
 
66
- const { label = 'Uitlijning', messages } = defineProps<NexxtEditorJustifyContentProps>()
72
+ const {
73
+ label = 'Uitlijning',
74
+ messages,
75
+ options: offered,
76
+ } = defineProps<NexxtEditorJustifyContentProps>()
67
77
 
68
78
  /** The selected justification, as the Tailwind class to apply. */
69
79
  const model = defineModel<EditorJustifyContentValue>({ required: true })
@@ -88,8 +98,12 @@ const ICONS: Record<
88
98
  between: 'distribute-spacing-horizontal',
89
99
  }
90
100
 
101
+ // Filtered from the full list rather than mapped from `offered`, so the order is
102
+ // always the canonical one and a duplicate in `offered` cannot double a button.
91
103
  const options = computed<NexxtToggleGroupOption[]>(() =>
92
- EDITOR_JUSTIFY_CONTENT_OPTIONS.map(({ token }) => ({
104
+ EDITOR_JUSTIFY_CONTENT_OPTIONS.filter(
105
+ ({ token }) => !offered || offered.includes(`justify-${token}`),
106
+ ).map(({ token }) => ({
93
107
  value: `justify-${token}`,
94
108
  icon: ICONS[token],
95
109
  ariaLabel: copy.value[token],
@@ -110,6 +124,6 @@ const selected = computed({
110
124
 
111
125
  <template>
112
126
  <NexxtEditorSettingsBlock :label="label">
113
- <NexxtToggleGroup v-model="selected" :options="options" />
127
+ <NexxtToggleGroup v-model="selected" :options="options" :aria-label="label" />
114
128
  </NexxtEditorSettingsBlock>
115
129
  </template>