@tekkare/romulus 0.1.21 → 0.1.22
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/module.json
CHANGED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmSegmented - un choix unique parmi quelques options, rendu comme un rail
|
|
3
|
+
segmente : les options cote a cote dans un cadre, et un pave colore qui
|
|
4
|
+
glisse sur celle qui est retenue.
|
|
5
|
+
|
|
6
|
+
A preferer a une rangee de boutons radio des que les options sont courtes et
|
|
7
|
+
peu nombreuses. Une pastille ronde par option ne dit pas qu'elles sont
|
|
8
|
+
exclusives : il faut le deduire de la forme du controle. Le rail, lui, montre
|
|
9
|
+
qu'une seule case est prise a la fois, et occupe la moitie de la hauteur.
|
|
10
|
+
Au-dela d'une dizaine d'options, ou si les libelles sont longs, c'est une
|
|
11
|
+
liste deroulante qu'il faut, pas ce composant.
|
|
12
|
+
|
|
13
|
+
Le pave est positionne par mesure du DOM, pas par calcul de fractions : les
|
|
14
|
+
segments n'ont pas la meme largeur (« Product » contre « Molecule (ATC5) »),
|
|
15
|
+
et la largeur d'un libelle depend de la police, donc du moment ou elle finit
|
|
16
|
+
de charger. Un ResizeObserver reprend la mesure a chaque changement de taille.
|
|
17
|
+
Il porte aussi `top` et `height`, pour que le pave suive quand le rail passe
|
|
18
|
+
sur deux lignes faute de place.
|
|
19
|
+
|
|
20
|
+
Clavier : le groupe est un `radiogroup` ARIA, les fleches deplacent la
|
|
21
|
+
selection, comme sur un vrai groupe de boutons radio.
|
|
22
|
+
-->
|
|
23
|
+
<script setup lang="ts">
|
|
24
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
25
|
+
|
|
26
|
+
export interface RmSegmentedOption {
|
|
27
|
+
/** Valeur portee par le v-model. */
|
|
28
|
+
value: string | number
|
|
29
|
+
label: string
|
|
30
|
+
/** Icone Phosphor, nom en kebab-case sans le prefixe `ph-`. */
|
|
31
|
+
icon?: string
|
|
32
|
+
disabled?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const props = withDefaults(defineProps<{
|
|
36
|
+
/** Une chaine vaut `{ value: <la chaine>, label: <la chaine> }`. */
|
|
37
|
+
options: (string | RmSegmentedOption)[]
|
|
38
|
+
size?: 'sm' | 'md'
|
|
39
|
+
/** Le rail prend toute la largeur, segments a parts egales. */
|
|
40
|
+
block?: boolean
|
|
41
|
+
ariaLabel?: string
|
|
42
|
+
}>(), {
|
|
43
|
+
size: 'md',
|
|
44
|
+
block: false,
|
|
45
|
+
ariaLabel: undefined,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const model = defineModel<string | number | undefined>({ default: undefined })
|
|
49
|
+
|
|
50
|
+
const items = computed<RmSegmentedOption[]>(() =>
|
|
51
|
+
props.options.map(option =>
|
|
52
|
+
typeof option === 'string' ? { value: option, label: option } : option,
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
const activeIndex = computed(() => items.value.findIndex(item => item.value === model.value))
|
|
57
|
+
|
|
58
|
+
const root = ref<HTMLElement | null>(null)
|
|
59
|
+
const segments = ref<HTMLElement[]>([])
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Le pave n'existe pas tant qu'il n'a pas ete mesure : sans ca il apparait un
|
|
63
|
+
* instant sur la premiere option, a la mauvaise largeur, avant de sauter a sa
|
|
64
|
+
* place.
|
|
65
|
+
*/
|
|
66
|
+
const thumb = ref<Record<string, string> | null>(null)
|
|
67
|
+
|
|
68
|
+
function measure() {
|
|
69
|
+
const element = segments.value[activeIndex.value]
|
|
70
|
+
if (!element || !root.value) {
|
|
71
|
+
thumb.value = null
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
thumb.value = {
|
|
75
|
+
width: `${element.offsetWidth}px`,
|
|
76
|
+
height: `${element.offsetHeight}px`,
|
|
77
|
+
transform: `translate(${element.offsetLeft}px, ${element.offsetTop}px)`,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const remeasure = () => nextTick(measure)
|
|
82
|
+
|
|
83
|
+
function select(item: RmSegmentedOption) {
|
|
84
|
+
if (item.disabled || item.value === model.value) return
|
|
85
|
+
model.value = item.value
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Fleches : on saute les options desactivees, et on boucle aux extremites. */
|
|
89
|
+
function move(step: number) {
|
|
90
|
+
const total = items.value.length
|
|
91
|
+
if (!total) return
|
|
92
|
+
let index = activeIndex.value < 0 ? 0 : activeIndex.value
|
|
93
|
+
for (let hop = 0; hop < total; hop++) {
|
|
94
|
+
index = (index + step + total) % total
|
|
95
|
+
const candidate = items.value[index]
|
|
96
|
+
if (candidate && !candidate.disabled) {
|
|
97
|
+
model.value = candidate.value
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let observer: ResizeObserver | null = null
|
|
104
|
+
|
|
105
|
+
onMounted(() => {
|
|
106
|
+
remeasure()
|
|
107
|
+
// La police est chargee apres le premier rendu : sans cette reprise, le pave
|
|
108
|
+
// garde la largeur mesuree sur la police de repli.
|
|
109
|
+
if (typeof document !== 'undefined' && 'fonts' in document) {
|
|
110
|
+
(document as any).fonts?.ready?.then(remeasure).catch(() => {})
|
|
111
|
+
}
|
|
112
|
+
if (root.value && typeof ResizeObserver !== 'undefined') {
|
|
113
|
+
observer = new ResizeObserver(measure)
|
|
114
|
+
observer.observe(root.value)
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
onBeforeUnmount(() => {
|
|
119
|
+
observer?.disconnect()
|
|
120
|
+
observer = null
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
watch(() => [model.value, items.value.length, props.block], remeasure)
|
|
124
|
+
</script>
|
|
125
|
+
|
|
126
|
+
<template>
|
|
127
|
+
<div
|
|
128
|
+
ref="root"
|
|
129
|
+
class="rm-seg"
|
|
130
|
+
:class="[`rm-seg--${size}`, { 'rm-seg--block': block }]"
|
|
131
|
+
role="radiogroup"
|
|
132
|
+
:aria-label="ariaLabel"
|
|
133
|
+
@keydown.left.prevent="move(-1)"
|
|
134
|
+
@keydown.up.prevent="move(-1)"
|
|
135
|
+
@keydown.right.prevent="move(1)"
|
|
136
|
+
@keydown.down.prevent="move(1)"
|
|
137
|
+
>
|
|
138
|
+
<span v-if="thumb" class="rm-seg__thumb" :style="thumb" aria-hidden="true" />
|
|
139
|
+
|
|
140
|
+
<button
|
|
141
|
+
v-for="(item, index) in items"
|
|
142
|
+
:key="item.value"
|
|
143
|
+
ref="segments"
|
|
144
|
+
class="rm-seg__option"
|
|
145
|
+
:class="{
|
|
146
|
+
'rm-seg__option--active': index === activeIndex,
|
|
147
|
+
'rm-seg__option--sep': index > 0 && index !== activeIndex && index - 1 !== activeIndex,
|
|
148
|
+
}"
|
|
149
|
+
type="button"
|
|
150
|
+
role="radio"
|
|
151
|
+
:aria-checked="index === activeIndex"
|
|
152
|
+
:disabled="item.disabled"
|
|
153
|
+
:tabindex="index === activeIndex || (activeIndex < 0 && index === 0) ? 0 : -1"
|
|
154
|
+
@click="select(item)"
|
|
155
|
+
>
|
|
156
|
+
<i v-if="item.icon" :class="`ph ph-${item.icon}`" />
|
|
157
|
+
{{ item.label }}
|
|
158
|
+
</button>
|
|
159
|
+
</div>
|
|
160
|
+
</template>
|
|
161
|
+
|
|
162
|
+
<style scoped>
|
|
163
|
+
.rm-seg{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-lg);display:inline-flex;flex-wrap:wrap;font-family:var(--font-sans);gap:2px;max-width:100%;padding:3px;position:relative}.rm-seg--block{display:flex;width:100%}.rm-seg--block .rm-seg__option{flex:1}.rm-seg__thumb{background:var(--color-primary);border-radius:var(--radius-md);left:0;position:absolute;top:0;transition:transform var(--duration-base) var(--ease-in-out),width var(--duration-base) var(--ease-in-out),height var(--duration-base) var(--ease-in-out);z-index:0}.rm-seg__option{align-items:center;-moz-appearance:none;appearance:none;-webkit-appearance:none;background:none;border:none;border-radius:var(--radius-md);box-shadow:none;color:var(--text-secondary);cursor:pointer;display:inline-flex;font-family:inherit;font-weight:500;gap:6px;justify-content:center;margin:0;position:relative;transition:color var(--duration-fast) var(--ease-in-out);white-space:nowrap;z-index:1}.rm-seg--md .rm-seg__option{font-size:14px;line-height:20px;padding:5px 12px}.rm-seg--sm .rm-seg__option{font-size:13px;line-height:18px;padding:3px 10px}.rm-seg__option i{font-size:16px;line-height:1}.rm-seg__option:hover:not(:disabled):not(.rm-seg__option--active){color:var(--color-primary)}.rm-seg__option--active{color:#fff;font-weight:600}.rm-seg__option:disabled{color:var(--text-muted);cursor:not-allowed;opacity:.6}.rm-seg__option:focus-visible{outline:2px solid var(--color-primary);outline-offset:1px}.rm-seg__option--sep:before{background:var(--border-default);content:"";height:60%;left:-2px;position:absolute;top:20%;width:1px}@media (prefers-reduced-motion:reduce){.rm-seg__thumb{transition:none}}
|
|
164
|
+
</style>
|