@nexxtmove/ui 1.32.0 → 1.34.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.
@@ -11,7 +11,7 @@
11
11
  * `full` is last and carries no px: it is a pill, not a size on the scale, so
12
12
  * the readout names it instead of measuring it.
13
13
  */
14
- const RADIUS_STEPS = [
14
+ export const EDITOR_RADIUS_STEPS = [
15
15
  { token: 'none', px: 0 },
16
16
  { token: 'xs', px: 2 },
17
17
  { token: 'sm', px: 4 },
@@ -28,7 +28,7 @@ const RADIUS_STEPS = [
28
28
  * The radius as the editor needs it: the Tailwind class itself, so the consumer
29
29
  * can put the model value straight on the element it styles.
30
30
  */
31
- export type EditorRadiusValue = `rounded-${(typeof RADIUS_STEPS)[number]['token']}`
31
+ export type EditorRadiusValue = `rounded-${(typeof EDITOR_RADIUS_STEPS)[number]['token']}`
32
32
 
33
33
  /** Overridable copy of the value readout. Every key is optional. */
34
34
  export interface EditorRadiusMessages {
@@ -75,7 +75,7 @@ const model = defineModel<EditorRadiusValue>({ required: true })
75
75
 
76
76
  const copy = computed(() => ({ ...DEFAULT_MESSAGES, ...messages }))
77
77
 
78
- const maxIndex = RADIUS_STEPS.length - 1
78
+ const maxIndex = EDITOR_RADIUS_STEPS.length - 1
79
79
 
80
80
  /**
81
81
  * Position of the current value on the step list. A value that is not on the
@@ -86,7 +86,7 @@ const maxIndex = RADIUS_STEPS.length - 1
86
86
  const stepIndex = computed(() =>
87
87
  Math.max(
88
88
  0,
89
- RADIUS_STEPS.findIndex(({ token }) => `rounded-${token}` === model.value),
89
+ EDITOR_RADIUS_STEPS.findIndex(({ token }) => `rounded-${token}` === model.value),
90
90
  ),
91
91
  )
92
92
 
@@ -95,12 +95,12 @@ const stepIndex = computed(() =>
95
95
  const sliderValue = computed({
96
96
  get: () => [stepIndex.value],
97
97
  set: ([index]) => {
98
- model.value = `rounded-${RADIUS_STEPS[index].token}`
98
+ model.value = `rounded-${EDITOR_RADIUS_STEPS[index].token}`
99
99
  },
100
100
  })
101
101
 
102
102
  const displayValue = computed(() => {
103
- const { px } = RADIUS_STEPS[stepIndex.value]
103
+ const { px } = EDITOR_RADIUS_STEPS[stepIndex.value]
104
104
 
105
105
  return px === null ? copy.value.full : `${px} ${copy.value.unit}`
106
106
  })
@@ -0,0 +1,107 @@
1
+ <script setup lang="ts">
2
+ import { computed, useId } from 'vue'
3
+ import { usePreferredReducedMotion } from '@vueuse/core'
4
+ import NexxtIcon from '../../atoms/Icon/Icon.vue'
5
+
6
+ interface NexxtPickerGroupProps {
7
+ /** Heading of the group, and the accessible name of its `role="group"`. */
8
+ label: string
9
+ /**
10
+ * Whether the heading folds the group. `false` turns the heading into a plain label: no
11
+ * chevron, no click handler, no `aria-expanded`.
12
+ */
13
+ collapsible?: boolean
14
+ }
15
+
16
+ defineOptions({ name: 'NexxtPickerGroup' })
17
+
18
+ const { label, collapsible = true } = defineProps<NexxtPickerGroupProps>()
19
+
20
+ /** Whether the options are shown. A closed group has its options out of the DOM. */
21
+ const open = defineModel<boolean>('open', { default: true })
22
+
23
+ defineSlots<{
24
+ /** The options: `NexxtPickerItem`s, or anything else with `role="option"`. */
25
+ default(): unknown
26
+ }>()
27
+
28
+ const headingId = useId()
29
+
30
+ const preferredMotion = usePreferredReducedMotion()
31
+ // `motion-reduce:transition-none` alone leaves Vue reading the 120ms off the leaving wrapper, so
32
+ // the options of a "closed" group would linger; without CSS mode they go in the same patch
33
+ const reduced = computed(() => preferredMotion.value === 'reduce')
34
+
35
+ const HEADING_CLASSES = {
36
+ base: 'flex h-7 items-center gap-1.5 rounded-lg px-2 extra-small-semibold tracking-wide text-gray-700 uppercase select-none',
37
+ collapsible: 'cursor-pointer hover:bg-gray-50',
38
+ static: 'cursor-default',
39
+ }
40
+
41
+ const CHEVRON_CLASSES = {
42
+ base: 'extra-small-normal transition-transform motion-reduce:transition-none',
43
+ open: 'duration-160 ease-out',
44
+ closed: '-rotate-90 duration-120 ease-in',
45
+ }
46
+
47
+ const headingClass = computed(() => [
48
+ HEADING_CLASSES.base,
49
+ collapsible ? HEADING_CLASSES.collapsible : HEADING_CLASSES.static,
50
+ ])
51
+
52
+ const chevronClass = computed(() => [
53
+ CHEVRON_CLASSES.base,
54
+ open.value ? CHEVRON_CLASSES.open : CHEVRON_CLASSES.closed,
55
+ ])
56
+
57
+ // Bound through `v-on`, so a static heading has no click handler at all
58
+ const headingListeners = computed(() =>
59
+ collapsible ? { click: () => (open.value = !open.value) } : {},
60
+ )
61
+
62
+ // Options on their way out can no longer be clicked, and assistive tech skips them
63
+ const onBeforeLeave = (el: Element) => el.setAttribute('inert', '')
64
+ </script>
65
+
66
+ <template>
67
+ <div
68
+ role="group"
69
+ :aria-labelledby="headingId"
70
+ :aria-expanded="collapsible ? open : undefined"
71
+ class="flex flex-col not-first:mt-2"
72
+ >
73
+ <!-- Not a button and not a tab stop: a listbox may only hold options and groups. The keyboard
74
+ folds a group with ←/→ from the listbox. `mousedown.prevent` keeps focus where it was. -->
75
+ <div role="presentation" :class="headingClass" v-on="headingListeners" @mousedown.prevent>
76
+ <span
77
+ v-if="collapsible"
78
+ class="flex size-3 shrink-0 items-center justify-center text-gray-700"
79
+ >
80
+ <NexxtIcon name="chevron-down" aria-hidden="true" :class="chevronClass" />
81
+ </span>
82
+ <span :id="headingId">{{ label }}</span>
83
+ </div>
84
+
85
+ <!-- No row class on the wrapper itself: after entering it is plain `auto`, so a base class
86
+ never fights the from/to classes -->
87
+ <Transition
88
+ :css="!reduced"
89
+ enter-active-class="transition-[grid-template-rows,opacity] duration-160 ease-out motion-reduce:transition-none"
90
+ enter-from-class="grid-rows-[0fr] opacity-0"
91
+ enter-to-class="grid-rows-[1fr] opacity-100"
92
+ leave-active-class="transition-[grid-template-rows,opacity] duration-120 ease-in motion-reduce:transition-none"
93
+ leave-from-class="grid-rows-[1fr] opacity-100"
94
+ leave-to-class="grid-rows-[0fr] opacity-0"
95
+ @before-leave="onBeforeLeave"
96
+ >
97
+ <div v-if="open" class="grid">
98
+ <div class="min-h-0 overflow-hidden">
99
+ <!-- Padding here rather than on the clipping layer, so nothing is left at 0fr -->
100
+ <div class="flex flex-col gap-0.5 pt-0.5">
101
+ <slot />
102
+ </div>
103
+ </div>
104
+ </div>
105
+ </Transition>
106
+ </div>
107
+ </template>