@nexxtmove/ui 1.3.3 → 1.4.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
@@ -27,6 +27,7 @@ var s = {
27
27
  NexxtInputField: "components/InputField/InputField.vue",
28
28
  NexxtInputSelect: "components/InputSelect/InputSelect.vue",
29
29
  NexxtModal: "components/Modal/Modal.vue",
30
+ NexxtModalFooter: "components/ModalFooter/ModalFooter.vue",
30
31
  NexxtPagination: "components/Pagination/Pagination.vue",
31
32
  NexxtPresenceIndicator: "components/PresenceIndicator/PresenceIndicator.vue",
32
33
  NexxtProgressBar: "components/ProgressBar/ProgressBar.vue",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nexxtmove/ui",
3
3
  "type": "module",
4
- "version": "1.3.3",
4
+ "version": "1.4.0",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.ts",
@@ -1,28 +1,69 @@
1
1
  <script lang="ts" setup>
2
+ import { computed } from 'vue'
2
3
  import NexxtIcon from '../Icon/Icon.vue'
3
4
 
5
+ type AnnouncementVariant = 'info' | 'success' | 'warning' | 'danger' | 'neutral'
6
+
4
7
  interface NexxtAnnouncementProps {
8
+ /**
9
+ * Raw CSS background colour, applied inline. Intended for colours supplied by
10
+ * the API; wins over `variant`. Defaults to `blue` when no `variant` is set.
11
+ */
5
12
  color?: string
13
+ /**
14
+ * Raw CSS text colour, applied inline. Snake_case mirrors the API field name.
15
+ * Wins over `variant`. Defaults to `white` when no `variant` is set.
16
+ */
6
17
  text_color?: string
18
+ /** Icon shown at the start of the banner. */
7
19
  icon?: InstanceType<typeof NexxtIcon>['name']
20
+ /** Show the close button that emits `hide`. */
8
21
  showHideButton?: boolean
22
+ /** Semantic colour scheme for app-authored banners, applied as design tokens. */
23
+ variant?: AnnouncementVariant
9
24
  }
10
25
 
11
26
  const {
12
- color = 'blue',
13
- text_color = 'white',
27
+ color,
28
+ text_color,
14
29
  icon = 'megaphone',
15
30
  showHideButton = true,
31
+ variant,
16
32
  } = defineProps<NexxtAnnouncementProps>()
17
33
 
18
34
  const emit = defineEmits<{ hide: [] }>()
35
+
36
+ // Token-based schemes for app-authored banners.
37
+ const variantClasses: Record<AnnouncementVariant, string> = {
38
+ info: 'bg-cornflower-blue-50 text-cornflower-blue-900',
39
+ success: 'bg-green-50 text-green-900',
40
+ warning: 'bg-orange-50 text-orange-900',
41
+ danger: 'bg-brick-50 text-brick-900',
42
+ neutral: 'bg-gray-50 text-gray-900',
43
+ }
44
+
45
+ // Precedence, deliberate and load-bearing:
46
+ // 1. An explicitly supplied `color` / `text_color` always wins. They carry raw
47
+ // CSS values coming from the API and are applied inline, so they override
48
+ // any variant class regardless of specificity.
49
+ // 2. Otherwise, a `variant` applies token classes.
50
+ // 3. With neither, the historical inline defaults (blue / white) are used, so
51
+ // existing consumers that pass nothing render exactly as before.
52
+ const style = computed(() => ({
53
+ background: color ?? (variant ? undefined : 'blue'),
54
+ color: text_color ?? (variant ? undefined : 'white'),
55
+ }))
19
56
  </script>
20
57
 
21
58
  <template>
22
59
  <div
23
60
  class="grid items-center gap-x-3 overflow-hidden rounded-md px-4 py-3"
24
- :class="showHideButton ? 'grid-cols-[auto_1fr_auto]' : 'grid-cols-[auto_1fr]'"
25
- :style="{ background: color, color: text_color }"
61
+ :class="[
62
+ showHideButton ? 'grid-cols-[auto_1fr_auto]' : 'grid-cols-[auto_1fr]',
63
+ variant ? variantClasses[variant] : '',
64
+ ]"
65
+ :style="style"
66
+ :data-variant="variant"
26
67
  >
27
68
  <NexxtIcon class="text-lg" :name="icon" />
28
69
  <slot />
@@ -1,17 +1,25 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
+ import NexxtIcon, { type NexxtIconName } from '../Icon/Icon.vue'
3
4
 
4
5
  interface NexxtChipProps {
5
- variant?: 'default' | 'warning' | 'success' | 'danger' | 'neutral' | 'accent'
6
+ /** Colour scheme of the chip. */
7
+ variant?: 'default' | 'warning' | 'success' | 'danger' | 'neutral' | 'accent' | 'yellow'
8
+ /** Render as a light, outlined chip instead of a solid one. */
6
9
  ghost?: boolean
10
+ /** Optional icon rendered before the default slot. */
11
+ icon?: NexxtIconName
7
12
  }
8
13
 
9
14
  defineOptions({
10
15
  name: 'NexxtChip',
11
16
  })
12
17
 
13
- const { variant = 'default', ghost = false } = defineProps<NexxtChipProps>()
18
+ const { variant = 'default', ghost = false, icon } = defineProps<NexxtChipProps>()
14
19
 
20
+ // Every variant states its own text colour explicitly rather than relying on a
21
+ // shared default, so a variant that needs to differ can do so without changing
22
+ // the shape of the data.
15
23
  const styles = computed(() => {
16
24
  if (ghost) {
17
25
  switch (variant) {
@@ -25,6 +33,9 @@ const styles = computed(() => {
25
33
  return { bg: 'bg-gray-100', border: 'border border-gray-600', text: 'text-gray-700' }
26
34
  case 'accent':
27
35
  return { bg: 'bg-purple-50', border: 'border border-purple-500', text: 'text-purple-500' }
36
+ case 'yellow':
37
+ // yellow-500 on yellow-50 is unreadable, so the ghost text steps down to yellow-700
38
+ return { bg: 'bg-yellow-50', border: 'border border-yellow-500', text: 'text-yellow-700' }
28
39
  default:
29
40
  return {
30
41
  bg: 'bg-cornflower-blue-50',
@@ -44,6 +55,11 @@ const styles = computed(() => {
44
55
  return { bg: 'bg-gray-700', border: '', text: 'text-white' }
45
56
  case 'accent':
46
57
  return { bg: 'bg-purple-600', border: '', text: 'text-white' }
58
+ case 'yellow':
59
+ // White on yellow-500 measures 1.57:1, below the 4.5:1 WCAG AA threshold
60
+ // that applies to this chip's 12px semibold label. Kept deliberately;
61
+ // tracked with the other contrast findings in the a11y backlog.
62
+ return { bg: 'bg-yellow-500', border: '', text: 'text-white' }
47
63
  default:
48
64
  return { bg: 'bg-cornflower-blue-600', border: '', text: 'text-white' }
49
65
  }
@@ -53,9 +69,17 @@ const styles = computed(() => {
53
69
  <template>
54
70
  <span
55
71
  class="inline-flex h-5 flex-col items-center justify-center gap-2.5 rounded-[200px]"
56
- :class="[styles.bg, styles.border, $slots.close ? 'pr-2 pl-4' : 'px-4']"
72
+ :class="[
73
+ styles.bg,
74
+ styles.border,
75
+ icon ? ($slots.close ? 'pr-2 pl-3' : 'px-3') : $slots.close ? 'pr-2 pl-4' : 'px-4',
76
+ ]"
57
77
  >
58
- <span class="justify-start text-center extra-small-semibold" :class="styles.text">
78
+ <span
79
+ class="justify-start text-center extra-small-semibold"
80
+ :class="[styles.text, icon ? 'inline-flex items-center gap-1.5' : '']"
81
+ >
82
+ <NexxtIcon v-if="icon" :name="icon" class="shrink-0" />
59
83
  <slot>Chip</slot>
60
84
  <slot v-if="$slots.close" name="close"></slot>
61
85
  </span>
@@ -1,7 +1,7 @@
1
1
  <script lang="ts" setup></script>
2
2
 
3
3
  <template>
4
- <div class="rounded-xl border border-gray-200">
4
+ <div class="rounded-xl border border-gray-200 bg-white">
5
5
  <div class="border-b border-gray-200 py-2.5 pr-3 pl-8">
6
6
  <slot name="title">Title</slot>
7
7
  </div>
@@ -4,9 +4,14 @@ import { useFloating, autoUpdate, flip, shift, offset, size } from '@floating-ui
4
4
  import type { Placement } from '@floating-ui/vue'
5
5
 
6
6
  export interface NexxtFloatingPanelProps {
7
+ /** Preferred side and alignment of the panel relative to the trigger. */
7
8
  placement?: Placement
9
+ /** Distance in pixels between the trigger and the panel. */
8
10
  offsetDistance?: number
11
+ /** Stretch the panel to at least the trigger's width. */
9
12
  matchWidth?: boolean
13
+ /** Wrap the content slot in the shared panel surface (rounded, bordered, white, shadowed). */
14
+ surface?: boolean
10
15
  }
11
16
 
12
17
  defineOptions({ name: 'NexxtFloatingPanel' })
@@ -15,6 +20,7 @@ const props = withDefaults(defineProps<NexxtFloatingPanelProps>(), {
15
20
  placement: 'bottom-start',
16
21
  offsetDistance: 8,
17
22
  matchWidth: false,
23
+ surface: false,
18
24
  })
19
25
 
20
26
  const emit = defineEmits<{
@@ -167,7 +173,30 @@ onUnmounted(() => {
167
173
  -->
168
174
  <Transition v-bind="transitionClasses" @after-leave="isLeaving = false">
169
175
  <div v-if="isOpen && isReady">
170
- <slot name="content" :is-open="isOpen" :close="close" :toggle="toggle" />
176
+ <!--
177
+ FloatingPanel is a positioning primitive: by default it adds no surface
178
+ of its own and the content slot renders bare, exactly as before. The
179
+ surface wrapper is an extra element that only exists when opted into,
180
+ so the default render is unchanged for existing consumers. The radius
181
+ is fixed by design and deliberately not configurable.
182
+
183
+ overflow-hidden belongs with the radius: without it, content drawn to
184
+ the panel's edge -- a scrolling list especially -- paints over the
185
+ rounded corners. Consumers cannot supply it themselves once they adopt
186
+ the surface, because the element carrying the radius is this one, and
187
+ clipping on an inner element has nothing left to clip against.
188
+
189
+ It does not trap nested floating content: FloatingPanel positions with
190
+ strategy 'fixed', and overflow on an ancestor does not clip a fixed
191
+ descendant unless that ancestor also establishes a containing block.
192
+ -->
193
+ <div
194
+ v-if="surface"
195
+ class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm"
196
+ >
197
+ <slot name="content" :is-open="isOpen" :close="close" :toggle="toggle" />
198
+ </div>
199
+ <slot v-else name="content" :is-open="isOpen" :close="close" :toggle="toggle" />
171
200
  </div>
172
201
  </Transition>
173
202
  </div>
@@ -0,0 +1,77 @@
1
+ <script lang="ts" setup>
2
+ import NexxtButton from '../Button/Button.vue'
3
+
4
+ // Kept in step with NexxtButton's own `variant` prop, widened with `string` so a
5
+ // call site may bind a computed value (`options.variant ?? 'primary'`) without a
6
+ // literal union rejecting it. The known values still autocomplete.
7
+ type ButtonVariant = InstanceType<typeof NexxtButton>['$props']['variant']
8
+
9
+ // Derived from NexxtButton rather than restated, so the two cannot drift when
10
+ // the icon set changes.
11
+ type ButtonIcon = InstanceType<typeof NexxtButton>['$props']['icon']
12
+
13
+ interface NexxtModalFooterProps {
14
+ /**
15
+ * Label of the cancel button. Required: this library has no i18n layer, so
16
+ * copy always arrives from the consumer rather than being defaulted here.
17
+ */
18
+ cancelLabel: string
19
+ /**
20
+ * Disable the cancel button. Use while a confirmed action is in flight, so a
21
+ * destructive operation cannot be dismissed halfway through.
22
+ */
23
+ cancelDisabled?: boolean
24
+ /** Label of the action button. */
25
+ confirmLabel: string
26
+ /**
27
+ * Variant of the action button. Left undefined it falls through to
28
+ * NexxtButton's own default, so it renders like a plain `<NexxtButton>`.
29
+ */
30
+ confirmVariant?: ButtonVariant | (string & {})
31
+ /** Icon rendered on the action button, before its label. */
32
+ confirmIcon?: ButtonIcon
33
+ /** Put the action button in its loading state. */
34
+ confirmLoading?: boolean
35
+ /** Disable the action button. */
36
+ confirmDisabled?: boolean
37
+ }
38
+
39
+ defineOptions({
40
+ name: 'NexxtModalFooter',
41
+ })
42
+
43
+ const {
44
+ cancelDisabled = false,
45
+ confirmLoading = false,
46
+ confirmDisabled = false,
47
+ } = defineProps<NexxtModalFooterProps>()
48
+
49
+ const emit = defineEmits<{ cancel: []; confirm: [] }>()
50
+ </script>
51
+
52
+ <template>
53
+ <div class="flex justify-end gap-2">
54
+ <!--
55
+ This comment lives INSIDE the root div on purpose. A comment before the
56
+ root makes the component multi-root, which leaves a consumer's fallthrough
57
+ class/id with nowhere unambiguous to land and changes what the root is
58
+ between dev and production, since the compiler strips comments in prod.
59
+
60
+ Modal's #footer wrapper already supplies the padding, so this component
61
+ adds none. It keeps its own row alignment so it also reads correctly
62
+ outside a Modal, without doubling up on anything Modal provides.
63
+ -->
64
+ <NexxtButton variant="secondary" :disabled="cancelDisabled" @click="emit('cancel')">
65
+ {{ cancelLabel }}
66
+ </NexxtButton>
67
+ <NexxtButton
68
+ :variant="confirmVariant as ButtonVariant"
69
+ :icon="confirmIcon"
70
+ :loading="confirmLoading"
71
+ :disabled="confirmDisabled"
72
+ @click="emit('confirm')"
73
+ >
74
+ {{ confirmLabel }}
75
+ </NexxtButton>
76
+ </div>
77
+ </template>
@@ -1,5 +1,5 @@
1
1
  <script lang="ts" setup>
2
- import { computed } from 'vue'
2
+ import { computed, ref, useTemplateRef, watch } from 'vue'
3
3
  import NexxtIcon from '../Icon/Icon.vue'
4
4
  import NexxtFloatingPanel from '../FloatingPanel/FloatingPanel.vue'
5
5
  import type Icon from '../Icon/Icon.vue'
@@ -11,16 +11,25 @@ const getValue = (opt: SelectOption): string | number => (typeof opt === 'string
11
11
 
12
12
  const generateId = () => 'select-' + Math.random().toString(36).slice(2, 10)
13
13
  const selectId = generateId()
14
+ const listboxId = `${selectId}-listbox`
15
+ const optionId = (index: number) => `${selectId}-option-${index}`
14
16
 
15
17
  defineOptions({ name: 'NexxtSelect' })
16
18
 
17
19
  interface NexxtSelectProps {
20
+ /** Options to choose from; plain strings or `{ label, value }` objects. */
18
21
  options: SelectOption[]
22
+ /** Text shown when nothing is selected. */
19
23
  placeholder?: string
24
+ /** Label rendered above the field. */
20
25
  label?: string
26
+ /** Render the field in its error state. */
21
27
  error?: boolean
28
+ /** Message shown below the field while `error` is true. */
22
29
  errorMessage?: string
30
+ /** Icon rendered inside the field, before the value. */
23
31
  leftIcon?: InstanceType<typeof Icon>['name']
32
+ /** Mark the field as required (renders an asterisk next to the label). */
24
33
  required?: boolean
25
34
  }
26
35
 
@@ -30,16 +39,86 @@ const props = withDefaults(defineProps<NexxtSelectProps>(), {
30
39
 
31
40
  const model = defineModel<string | number | null>({ default: null })
32
41
 
42
+ const panel = useTemplateRef('panel')
43
+
44
+ // Index of the keyboard-focused option. -1 means nothing is focused, which is
45
+ // also the state while the listbox is closed.
46
+ const activeIndex = ref(-1)
47
+
33
48
  const selectedLabel = computed(() => {
34
49
  if (model.value === null || model.value === undefined) return null
35
50
  const found = props.options.find((opt) => getValue(opt) === model.value)
36
51
  return found ? getLabel(found) : null
37
52
  })
38
53
 
54
+ const selectedIndex = computed(() =>
55
+ props.options.findIndex((opt) => getValue(opt) === model.value),
56
+ )
57
+
39
58
  const select = (opt: SelectOption, close: () => void) => {
40
59
  model.value = getValue(opt)
41
60
  close()
42
61
  }
62
+
63
+ // Opening via the keyboard focuses the selected option, or the first one.
64
+ const openWithFocus = () => {
65
+ activeIndex.value = selectedIndex.value >= 0 ? selectedIndex.value : 0
66
+ panel.value?.open()
67
+ }
68
+
69
+ const move = (key: string) => {
70
+ const count = props.options.length
71
+ if (!count) return
72
+ const current = activeIndex.value
73
+ if (key === 'Home') activeIndex.value = 0
74
+ else if (key === 'End') activeIndex.value = count - 1
75
+ else if (key === 'ArrowDown') activeIndex.value = current < 0 ? 0 : (current + 1) % count
76
+ else if (key === 'ArrowUp')
77
+ activeIndex.value = current < 0 ? count - 1 : (current - 1 + count) % count
78
+ }
79
+
80
+ const onKeydown = (event: KeyboardEvent) => {
81
+ const isOpen = panel.value?.isOpen ?? false
82
+
83
+ if (event.key === 'Escape') {
84
+ if (!isOpen) return
85
+ event.preventDefault()
86
+ panel.value?.close()
87
+ return
88
+ }
89
+
90
+ if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') {
91
+ event.preventDefault()
92
+ if (!isOpen) {
93
+ openWithFocus()
94
+ return
95
+ }
96
+ // Space only toggles; Enter commits the focused option.
97
+ if (event.key !== 'Enter') {
98
+ panel.value?.close()
99
+ return
100
+ }
101
+ const opt = props.options[activeIndex.value]
102
+ if (opt !== undefined) select(opt, () => panel.value?.close())
103
+ else panel.value?.close()
104
+ return
105
+ }
106
+
107
+ if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
108
+ event.preventDefault()
109
+ if (!isOpen) {
110
+ openWithFocus()
111
+ return
112
+ }
113
+ move(event.key)
114
+ }
115
+ }
116
+
117
+ // Keep the focused option in sync with the selection while closed, and drop it
118
+ // entirely once the listbox closes so aria-activedescendant is not left dangling.
119
+ watch(model, () => {
120
+ if (!panel.value?.isOpen) activeIndex.value = -1
121
+ })
43
122
  </script>
44
123
 
45
124
  <template>
@@ -53,19 +132,26 @@ const select = (opt: SelectOption, close: () => void) => {
53
132
  <span v-if="required" class="text-brick-500">*</span>
54
133
  </label>
55
134
 
56
- <NexxtFloatingPanel placement="bottom" match-width>
135
+ <NexxtFloatingPanel
136
+ ref="panel"
137
+ placement="bottom"
138
+ match-width
139
+ @close="activeIndex = -1"
140
+ @open="activeIndex = selectedIndex >= 0 ? selectedIndex : activeIndex"
141
+ >
57
142
  <template #trigger="{ isOpen, toggle }">
58
143
  <div
59
144
  :id="selectId"
60
145
  role="combobox"
61
146
  :aria-expanded="isOpen"
62
147
  aria-haspopup="listbox"
148
+ :aria-controls="isOpen ? listboxId : undefined"
149
+ :aria-activedescendant="isOpen && activeIndex >= 0 ? optionId(activeIndex) : undefined"
63
150
  tabindex="0"
64
151
  class="group flex w-full cursor-pointer items-center gap-2 rounded-lg ring transition-colors ease-out focus:ring-cornflower-blue-500 focus:outline-none"
65
152
  :class="[error ? 'ring-brick-400' : 'ring-gray-200', { 'pl-3': leftIcon, 'pr-3': true }]"
66
153
  @click="toggle"
67
- @keydown.enter.prevent="toggle"
68
- @keydown.space.prevent="toggle"
154
+ @keydown="onKeydown"
69
155
  >
70
156
  <span v-if="leftIcon" class="flex items-center">
71
157
  <NexxtIcon :name="leftIcon" />
@@ -85,15 +171,24 @@ const select = (opt: SelectOption, close: () => void) => {
85
171
  </template>
86
172
 
87
173
  <template #content="{ close }">
88
- <ul role="listbox" class="overflow-hidden rounded-lg bg-white shadow-xs ring ring-gray-100">
174
+ <ul
175
+ :id="listboxId"
176
+ role="listbox"
177
+ class="overflow-hidden rounded-lg bg-white shadow-xs ring ring-gray-100"
178
+ >
89
179
  <li
90
- v-for="opt in options"
180
+ v-for="(opt, index) in options"
181
+ :id="optionId(index)"
91
182
  :key="getValue(opt)"
92
183
  role="option"
93
184
  :aria-selected="model === getValue(opt)"
94
185
  class="flex cursor-pointer items-center justify-between px-4 py-3 text-sm text-gray-900 transition-colors duration-150 hover:bg-gray-50 hover:text-purple-600"
95
- :class="{ 'font-medium text-cornflower-blue-600': model === getValue(opt) }"
186
+ :class="[
187
+ { 'font-medium text-cornflower-blue-600': model === getValue(opt) },
188
+ index === activeIndex ? 'bg-gray-50' : '',
189
+ ]"
96
190
  @click="select(opt, close)"
191
+ @mousemove="activeIndex = index"
97
192
  >
98
193
  {{ getLabel(opt) }}
99
194
  <NexxtIcon v-if="model === getValue(opt)" name="check" class="text-xs" />
@@ -24,6 +24,7 @@
24
24
  "NexxtInputField": "components/InputField/InputField.vue",
25
25
  "NexxtInputSelect": "components/InputSelect/InputSelect.vue",
26
26
  "NexxtModal": "components/Modal/Modal.vue",
27
+ "NexxtModalFooter": "components/ModalFooter/ModalFooter.vue",
27
28
  "NexxtPagination": "components/Pagination/Pagination.vue",
28
29
  "NexxtPresenceIndicator": "components/PresenceIndicator/PresenceIndicator.vue",
29
30
  "NexxtProgressBar": "components/ProgressBar/ProgressBar.vue",
package/src/index.ts CHANGED
@@ -38,6 +38,7 @@ export { default as NexxtInfoBlock } from './components/InfoBlock/InfoBlock.vue'
38
38
  export { default as NexxtInputField } from './components/InputField/InputField.vue'
39
39
  export { default as NexxtInputSelect } from './components/InputSelect/InputSelect.vue'
40
40
  export { default as NexxtModal } from './components/Modal/Modal.vue'
41
+ export { default as NexxtModalFooter } from './components/ModalFooter/ModalFooter.vue'
41
42
  export { default as NexxtPagination } from './components/Pagination/Pagination.vue'
42
43
  export { default as NexxtPresenceIndicator } from './components/PresenceIndicator/PresenceIndicator.vue'
43
44
  export { default as NexxtProgressBar } from './components/ProgressBar/ProgressBar.vue'