@nexxtmove/ui 1.49.0 → 1.51.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
@@ -80,6 +80,7 @@ var s = {
80
80
  NexxtEditorSettingsBlock: "components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue",
81
81
  NexxtEditorSettingsGroup: "components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.vue",
82
82
  NexxtEditorAlignItems: "components/editor/molecules/EditorAlignItems/EditorAlignItems.vue",
83
+ NexxtEditorAnchor: "components/editor/molecules/EditorAnchor/EditorAnchor.vue",
83
84
  NexxtEditorBackgroundSize: "components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue",
84
85
  NexxtEditorBorder: "components/editor/molecules/EditorBorder/EditorBorder.vue",
85
86
  NexxtEditorColor: "components/editor/molecules/EditorColor/EditorColor.vue",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nexxtmove/ui",
3
3
  "type": "module",
4
- "version": "1.49.0",
4
+ "version": "1.51.0",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.ts",
@@ -57,6 +57,9 @@ const promoted = computed(() => group?.value.promoted ?? false)
57
57
 
58
58
  const labelTypography = computed(() => (promoted.value ? 'small-semibold' : 'small-normal'))
59
59
 
60
+ /** A promoted label reads as the group's title, so it takes the group heading's colour too. */
61
+ const labelColor = computed(() => (promoted.value ? 'text-cornflower-blue-700' : 'text-gray-900'))
62
+
60
63
  /**
61
64
  * A promoted label is a heading, so heading navigation finds it next to the
62
65
  * labelled groups. A switchable label stays the switch's <label>: a heading
@@ -78,9 +81,9 @@ const labelTag = computed(() => (promoted.value && group ? `h${group.value.headi
78
81
  -->
79
82
  <div class="flex items-center [&>label]:flex [&>label]:w-full [&>label]:gap-3">
80
83
  <Switch v-if="switchable" v-model="model" size="sm" color="blue" label-position="before">
81
- <span :class="['grow text-left', labelTypography, 'text-gray-900']">{{ label }}</span>
84
+ <span :class="['grow text-left', labelTypography, labelColor]">{{ label }}</span>
82
85
  </Switch>
83
- <component :is="labelTag" v-else :class="[labelTypography, 'text-gray-900']">
86
+ <component :is="labelTag" v-else :class="[labelTypography, labelColor]">
84
87
  {{ label }}
85
88
  </component>
86
89
  </div>
@@ -186,7 +186,7 @@ watch(open, (isOpen) => {
186
186
  :is="heading"
187
187
  v-if="active"
188
188
  :id="headingId"
189
- class="flex small-semibold text-gray-900"
189
+ class="flex small-semibold text-cornflower-blue-700"
190
190
  >
191
191
  <button
192
192
  ref="toggle"
@@ -205,7 +205,12 @@ watch(open, (isOpen) => {
205
205
  </span>
206
206
  </button>
207
207
  </component>
208
- <component :is="heading" v-else-if="label" :id="headingId" class="small-semibold text-gray-900">
208
+ <component
209
+ :is="heading"
210
+ v-else-if="label"
211
+ :id="headingId"
212
+ class="small-semibold text-cornflower-blue-700"
213
+ >
209
214
  {{ label }}
210
215
  </component>
211
216
 
@@ -0,0 +1,242 @@
1
+ <script lang="ts">
2
+ /** The horizontal part of an anchor point. */
3
+ export type EditorAnchorX = 'left' | 'center' | 'right'
4
+
5
+ /** The vertical part of an anchor point. */
6
+ export type EditorAnchorY = 'top' | 'center' | 'bottom'
7
+
8
+ /** The point something is anchored to: a corner, an edge or the middle. */
9
+ export interface EditorAnchorValue {
10
+ x: EditorAnchorX
11
+ y: EditorAnchorY
12
+ }
13
+
14
+ /**
15
+ * The nine cells, in reading order: row by row from the top, left to right.
16
+ * The labels are not in here; they arrive through `messages`.
17
+ */
18
+ export const EDITOR_ANCHOR_OPTIONS = [
19
+ { x: 'left', y: 'top' },
20
+ { x: 'center', y: 'top' },
21
+ { x: 'right', y: 'top' },
22
+ { x: 'left', y: 'center' },
23
+ { x: 'center', y: 'center' },
24
+ { x: 'right', y: 'center' },
25
+ { x: 'left', y: 'bottom' },
26
+ { x: 'center', y: 'bottom' },
27
+ { x: 'right', y: 'bottom' },
28
+ ] as const satisfies readonly EditorAnchorValue[]
29
+
30
+ /** Overridable copy for the nine cells. Every key is optional. */
31
+ export interface EditorAnchorMessages {
32
+ /** Name of the top left cell. Defaults to `'Linksboven'`. */
33
+ topLeft?: string
34
+ /** Name of the top cell. Defaults to `'Boven'`. */
35
+ top?: string
36
+ /** Name of the top right cell. Defaults to `'Rechtsboven'`. */
37
+ topRight?: string
38
+ /** Name of the left cell. Defaults to `'Links'`. */
39
+ left?: string
40
+ /** Name of the middle cell. Defaults to `'Midden'`. */
41
+ center?: string
42
+ /** Name of the right cell. Defaults to `'Rechts'`. */
43
+ right?: string
44
+ /** Name of the bottom left cell. Defaults to `'Linksonder'`. */
45
+ bottomLeft?: string
46
+ /** Name of the bottom cell. Defaults to `'Onder'`. */
47
+ bottom?: string
48
+ /** Name of the bottom right cell. Defaults to `'Rechtsonder'`. */
49
+ bottomRight?: string
50
+ }
51
+
52
+ export interface NexxtEditorAnchorProps {
53
+ /**
54
+ * Accessible name of the radiogroup. Defaults to the Dutch
55
+ * `'Plek van de afbeelding'`; the library has no i18n layer, so a consumer
56
+ * in another language passes its own copy in. Only shown when `showLabel`
57
+ * is set.
58
+ */
59
+ label?: string
60
+ /**
61
+ * Show `label` as a header above the grid. Off by default: the editor puts
62
+ * the name of the setting next to the grid itself.
63
+ */
64
+ showLabel?: boolean
65
+ /**
66
+ * Names of the nine cells. Defaults are Dutch and are merged per key, so
67
+ * overriding one keeps the defaults of the others. Copy is never translated
68
+ * inside the component.
69
+ */
70
+ messages?: EditorAnchorMessages
71
+ /** Block every interaction: no cell can be focused or chosen. */
72
+ disabled?: boolean
73
+ }
74
+
75
+ const DEFAULT_MESSAGES = {
76
+ topLeft: 'Linksboven',
77
+ top: 'Boven',
78
+ topRight: 'Rechtsboven',
79
+ left: 'Links',
80
+ center: 'Midden',
81
+ right: 'Rechts',
82
+ bottomLeft: 'Linksonder',
83
+ bottom: 'Onder',
84
+ bottomRight: 'Rechtsonder',
85
+ } satisfies Required<EditorAnchorMessages>
86
+
87
+ /** The messages key of each cell, by row and then by column. */
88
+ const MESSAGE_KEYS: Record<EditorAnchorY, Record<EditorAnchorX, keyof EditorAnchorMessages>> = {
89
+ top: { left: 'topLeft', center: 'top', right: 'topRight' },
90
+ center: { left: 'left', center: 'center', right: 'right' },
91
+ bottom: { left: 'bottomLeft', center: 'bottom', right: 'bottomRight' },
92
+ }
93
+
94
+ const COLUMNS = 3
95
+ const ROWS = EDITOR_ANCHOR_OPTIONS.length / COLUMNS
96
+
97
+ /** The cell that takes the tab stop while none is chosen. */
98
+ const MIDDLE = 4
99
+ </script>
100
+
101
+ <script lang="ts" setup>
102
+ import { computed, useId, useTemplateRef } from 'vue'
103
+
104
+ defineOptions({ name: 'NexxtEditorAnchor', inheritAttrs: false })
105
+
106
+ const {
107
+ label = 'Plek van de afbeelding',
108
+ showLabel = false,
109
+ messages,
110
+ disabled = false,
111
+ } = defineProps<NexxtEditorAnchorProps>()
112
+
113
+ /**
114
+ * The chosen cell. `null` while none is, e.g. for a stored position that no
115
+ * cell stands for.
116
+ */
117
+ const model = defineModel<EditorAnchorValue | null>({ required: true })
118
+
119
+ const labelId = useId()
120
+
121
+ const cellEls = useTemplateRef<HTMLButtonElement[]>('cellButtons')
122
+
123
+ const copy = computed(() => ({ ...DEFAULT_MESSAGES, ...messages }))
124
+
125
+ const selectedIndex = computed(() => {
126
+ const index = EDITOR_ANCHOR_OPTIONS.findIndex(
127
+ ({ x, y }) => x === model.value?.x && y === model.value?.y,
128
+ )
129
+ return index === -1 ? null : index
130
+ })
131
+
132
+ /** Roving tabindex: one tab stop, on the chosen cell or else on the middle. */
133
+ const tabStop = computed(() => selectedIndex.value ?? MIDDLE)
134
+
135
+ const cells = computed(() =>
136
+ EDITOR_ANCHOR_OPTIONS.map(({ x, y }, index) => {
137
+ const checked = index === selectedIndex.value
138
+ return {
139
+ index,
140
+ label: copy.value[MESSAGE_KEYS[y][x]],
141
+ checked,
142
+ tabindex: index === tabStop.value ? 0 : -1,
143
+ // -600, not -500: the white dot on -500 reaches only 2.78:1. The focus
144
+ // outline below is -600 for the same reason, against the white around it.
145
+ cellClass: [
146
+ checked ? 'bg-cornflower-blue-600' : !disabled && 'hover:bg-gray-100',
147
+ disabled ? 'cursor-not-allowed' : 'cursor-pointer',
148
+ ],
149
+ dotClass: checked ? 'size-2.5 bg-white' : 'size-1.5 bg-gray-400',
150
+ }
151
+ }),
152
+ )
153
+
154
+ /** The visible header names the group when shown; otherwise the label does. */
155
+ const groupName = computed(() =>
156
+ showLabel ? { 'aria-labelledby': labelId } : { 'aria-label': label },
157
+ )
158
+
159
+ const choose = (index: number) => {
160
+ const cell = EDITOR_ANCHOR_OPTIONS[index]
161
+
162
+ if (disabled || !cell || index === selectedIndex.value) return
163
+
164
+ model.value = { x: cell.x, y: cell.y }
165
+ }
166
+
167
+ /**
168
+ * The radio pattern in two dimensions: an arrow moves and chooses at once and
169
+ * stops at the edge; Home and End jump to the first and the last cell. A key
170
+ * with a modifier belongs to the browser or the OS (Alt+Left is back, Cmd+Left
171
+ * is line start), so it is left alone: no choice, no preventDefault.
172
+ */
173
+ const onKeydown = (event: KeyboardEvent, index: number) => {
174
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
175
+
176
+ const row = Math.floor(index / COLUMNS)
177
+ const column = index % COLUMNS
178
+ const next = {
179
+ ArrowLeft: column > 0 ? index - 1 : index,
180
+ ArrowRight: column < COLUMNS - 1 ? index + 1 : index,
181
+ ArrowUp: row > 0 ? index - COLUMNS : index,
182
+ ArrowDown: row < ROWS - 1 ? index + COLUMNS : index,
183
+ Home: 0,
184
+ End: EDITOR_ANCHOR_OPTIONS.length - 1,
185
+ }[event.key]
186
+
187
+ if (next === undefined) return
188
+
189
+ event.preventDefault()
190
+ choose(next)
191
+ cellEls.value?.[next]?.focus()
192
+ }
193
+ </script>
194
+
195
+ <template>
196
+ <p v-if="showLabel" :id="labelId" class="mb-1.5 small-normal text-gray-900">{{ label }}</p>
197
+ <!--
198
+ `w-fit`: the grid columns are fractions, so in block flow the ring would
199
+ stretch to the full width of the parent.
200
+ -->
201
+ <div
202
+ role="radiogroup"
203
+ v-bind="{ ...groupName, ...$attrs }"
204
+ :aria-disabled="disabled || undefined"
205
+ :class="[
206
+ 'grid w-fit grid-cols-3 gap-1 rounded-lg p-1 ring ring-gray-200',
207
+ disabled && 'opacity-50',
208
+ ]"
209
+ >
210
+ <!--
211
+ No `focus:outline-none` next to the focus-visible outline: in Tailwind v4
212
+ it sets the outline style to none, which the focus-visible variant does
213
+ not set back, so the ring would never show. The outline is -600, not
214
+ -500: -500 reaches 2.78:1 on white, short of the 3:1 for non-text.
215
+ -->
216
+ <button
217
+ v-for="cell in cells"
218
+ :key="cell.index"
219
+ ref="cellButtons"
220
+ type="button"
221
+ role="radio"
222
+ :aria-checked="cell.checked"
223
+ :aria-label="cell.label"
224
+ :tabindex="cell.tabindex"
225
+ :disabled="disabled"
226
+ :class="[
227
+ 'flex size-7 items-center justify-center rounded-md outline-cornflower-blue-600 transition-colors duration-150 ease-out focus-visible:outline-2 motion-reduce:transition-none',
228
+ cell.cellClass,
229
+ ]"
230
+ @click="choose(cell.index)"
231
+ @keydown="onKeydown($event, cell.index)"
232
+ >
233
+ <span
234
+ aria-hidden="true"
235
+ :class="[
236
+ 'rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none',
237
+ cell.dotClass,
238
+ ]"
239
+ />
240
+ </button>
241
+ </div>
242
+ </template>
@@ -41,6 +41,18 @@ export interface NexxtEditorBackgroundSizeProps {
41
41
  * others. Copy is never translated inside the component.
42
42
  */
43
43
  messages?: EditorBackgroundSizeMessages
44
+ /**
45
+ * The options to offer, as a subset of the three. They are always shown in
46
+ * the order of `EDITOR_BACKGROUND_SIZE_OPTIONS`, whatever order this list is
47
+ * in; a duplicate or unknown value is ignored. Defaults to all three.
48
+ */
49
+ options?: EditorBackgroundSizeValue[]
50
+ /**
51
+ * Render only the options, without the settings block and its header, for a
52
+ * consumer that puts the control in a block of its own. `label` then names
53
+ * the options toolbar only and is not shown.
54
+ */
55
+ bare?: boolean
44
56
  }
45
57
 
46
58
  const DEFAULT_MESSAGES = {
@@ -58,7 +70,12 @@ import type { NexxtToggleGroupOption } from '../../../molecules/ToggleGroup/Togg
58
70
 
59
71
  defineOptions({ name: 'NexxtEditorBackgroundSize' })
60
72
 
61
- const { label = 'Vulling', messages } = defineProps<NexxtEditorBackgroundSizeProps>()
73
+ const {
74
+ label = 'Vulling',
75
+ messages,
76
+ options: offered,
77
+ bare = false,
78
+ } = defineProps<NexxtEditorBackgroundSizeProps>()
62
79
 
63
80
  /** The selected background size, as the Tailwind class to apply. */
64
81
  const model = defineModel<EditorBackgroundSizeValue>({ required: true })
@@ -83,8 +100,12 @@ const ICONS: Record<
83
100
  auto: 'image',
84
101
  }
85
102
 
103
+ // Filtered from the full list rather than mapped from `offered`, so the order is
104
+ // always the canonical one and a duplicate in `offered` cannot double a button.
86
105
  const options = computed<NexxtToggleGroupOption[]>(() =>
87
- EDITOR_BACKGROUND_SIZE_OPTIONS.map(({ token }) => ({
106
+ EDITOR_BACKGROUND_SIZE_OPTIONS.filter(
107
+ ({ token }) => !offered || offered.includes(`bg-${token}`),
108
+ ).map(({ token }) => ({
88
109
  value: `bg-${token}`,
89
110
  icon: ICONS[token],
90
111
  ariaLabel: copy.value[token],
@@ -104,7 +125,8 @@ const selected = computed({
104
125
  </script>
105
126
 
106
127
  <template>
107
- <NexxtEditorSettingsBlock :label="label">
128
+ <NexxtToggleGroup v-if="bare" v-model="selected" :options="options" :aria-label="label" />
129
+ <NexxtEditorSettingsBlock v-else :label="label">
108
130
  <NexxtToggleGroup v-model="selected" :options="options" :aria-label="label" />
109
131
  </NexxtEditorSettingsBlock>
110
132
  </template>
@@ -77,6 +77,7 @@
77
77
  "NexxtEditorSettingsBlock": "components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue",
78
78
  "NexxtEditorSettingsGroup": "components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.vue",
79
79
  "NexxtEditorAlignItems": "components/editor/molecules/EditorAlignItems/EditorAlignItems.vue",
80
+ "NexxtEditorAnchor": "components/editor/molecules/EditorAnchor/EditorAnchor.vue",
80
81
  "NexxtEditorBackgroundSize": "components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue",
81
82
  "NexxtEditorBorder": "components/editor/molecules/EditorBorder/EditorBorder.vue",
82
83
  "NexxtEditorColor": "components/editor/molecules/EditorColor/EditorColor.vue",
package/src/index.ts CHANGED
@@ -39,6 +39,13 @@ export type {
39
39
  EditorAlignItemsMessages,
40
40
  EditorAlignItemsValue,
41
41
  } from './components/editor/molecules/EditorAlignItems/EditorAlignItems.vue'
42
+ export { EDITOR_ANCHOR_OPTIONS } from './components/editor/molecules/EditorAnchor/EditorAnchor.vue'
43
+ export type {
44
+ EditorAnchorMessages,
45
+ EditorAnchorValue,
46
+ EditorAnchorX,
47
+ EditorAnchorY,
48
+ } from './components/editor/molecules/EditorAnchor/EditorAnchor.vue'
42
49
  export { EDITOR_BACKGROUND_SIZE_OPTIONS } from './components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue'
43
50
  export type {
44
51
  EditorBackgroundSizeMessages,
@@ -204,6 +211,7 @@ export { default as NexxtDatePicker } from './components/organisms/DatePicker/Da
204
211
  export { default as NexxtDoughnutChart } from './components/molecules/DoughnutChart/DoughnutChart.vue'
205
212
  export { default as NexxtDropdownButton } from './components/organisms/DropdownButton/DropdownButton.vue'
206
213
  export { default as NexxtEditorAlignItems } from './components/editor/molecules/EditorAlignItems/EditorAlignItems.vue'
214
+ export { default as NexxtEditorAnchor } from './components/editor/molecules/EditorAnchor/EditorAnchor.vue'
207
215
  export { default as NexxtEditorBackgroundSize } from './components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue'
208
216
  export { default as NexxtEditorBorder } from './components/editor/molecules/EditorBorder/EditorBorder.vue'
209
217
  export { default as NexxtEditorColor } from './components/editor/molecules/EditorColor/EditorColor.vue'