@tekkare/romulus 0.1.88 → 0.1.90
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 +1 -1
- package/dist/runtime/components/A11yPanel.vue +194 -0
- package/dist/runtime/components/SearchBar.vue +72 -12
- package/dist/runtime/components/Segmented.vue +8 -2
- package/dist/runtime/components/Toggle.vue +47 -0
- package/dist/runtime/composables/useTheme.d.ts +12 -3
- package/dist/runtime/composables/useTheme.js +26 -4
- package/dist/runtime/plugins/theme.client.js +2 -2
- package/package.json +1 -1
package/dist/module.json
CHANGED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmA11yPanel - les reglages d'accessibilite, en rangees.
|
|
3
|
+
|
|
4
|
+
Porte le panneau « Accessibilite » du design system (`reference.html`) :
|
|
5
|
+
une rangee par reglage, chacune avec son icone, son intitule, une phrase
|
|
6
|
+
qui dit ce qu'elle change, et sa commande. La phrase n'est pas decorative :
|
|
7
|
+
« Simuler le daltonisme » et « Palette des charts » se ressemblent au point
|
|
8
|
+
de se confondre, et rien dans leur nom ne dit que l'un est un outil de test
|
|
9
|
+
et l'autre un reglage de confort.
|
|
10
|
+
|
|
11
|
+
Le panneau ne conserve aucun etat. Tout vit dans les classes de `body`,
|
|
12
|
+
posees par `useRomulusTheme`, et c'est ce qui permet a une preference
|
|
13
|
+
restauree au demarrage, ou a une bascule declenchee ailleurs (la bascule
|
|
14
|
+
jour / nuit du rail), d'etre reprise sans rien a synchroniser.
|
|
15
|
+
|
|
16
|
+
Trois reglages absents du panneau, alors que le composable les expose :
|
|
17
|
+
|
|
18
|
+
- La taille du texte et la densite d'affichage, tant que le CSS ne les
|
|
19
|
+
suit pas hors des composants Romulus. Les poser ferait une commande qui
|
|
20
|
+
change trois titres et laisse le reste de l'ecran en place, ce qui se lit
|
|
21
|
+
comme une panne.
|
|
22
|
+
- Le mode jour / nuit, qui a sa bascule a demeure (`RmThemeToggle`) :
|
|
23
|
+
l'enterrer dans un panneau qu'il faut ouvrir serait un recul.
|
|
24
|
+
-->
|
|
25
|
+
<script setup lang="ts">
|
|
26
|
+
import { computed } from 'vue'
|
|
27
|
+
import type { RmCvdMode } from '../composables/useTheme'
|
|
28
|
+
import { useRomulusTheme } from '../composables/useTheme'
|
|
29
|
+
|
|
30
|
+
const props = withDefaults(defineProps<{
|
|
31
|
+
lang?: 'fr' | 'en'
|
|
32
|
+
/** Chapeau du panneau. Vide, rien ne sort. */
|
|
33
|
+
description?: string
|
|
34
|
+
}>(), {
|
|
35
|
+
lang: 'fr',
|
|
36
|
+
description: undefined,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const {
|
|
40
|
+
cvd, chartPalette, chartDecal, highContrast, reduceMotion,
|
|
41
|
+
setCvd, setChartPalette, setChartDecal, setHighContrast, setReduceMotion, reset,
|
|
42
|
+
} = useRomulusTheme()
|
|
43
|
+
|
|
44
|
+
const WORDS = {
|
|
45
|
+
fr: {
|
|
46
|
+
intro: "Adaptez l'affichage à votre besoin. Les changements s'appliquent en temps réel à tout le dashboard et sont mémorisés pour vos prochaines sessions.",
|
|
47
|
+
cvd: 'Simuler le daltonisme',
|
|
48
|
+
cvdDesc: 'Affiche les charts à travers un filtre simulant la perception d\'un daltonien. Outil de test pour vérifier que vos visualisations restent lisibles.',
|
|
49
|
+
cvdNone: 'Aucun',
|
|
50
|
+
palette: 'Palette des charts',
|
|
51
|
+
paletteDesc: 'Default = palette Tekkare violet. CVD-safe = Okabe-Ito, scientifiquement validée pour les 3 types de daltonisme. Les charts se rechargent automatiquement avec la nouvelle palette.',
|
|
52
|
+
paletteDefault: 'Default Tekkare',
|
|
53
|
+
paletteCvd: 'CVD-safe Okabe-Ito',
|
|
54
|
+
decal: 'Patterns sur charts',
|
|
55
|
+
decalDesc: 'Ajoute un motif (hachures, points, lignes) sur chaque série, en plus de la couleur. Permet de distinguer les séries même en noir et blanc ou en daltonisme total.',
|
|
56
|
+
contrast: 'Contraste élevé',
|
|
57
|
+
contrastDesc: 'Renforce les couleurs de texte, épaissit les bordures, supprime les transparences.',
|
|
58
|
+
motion: 'Réduire les animations',
|
|
59
|
+
motionDesc: 'Désactive toutes les transitions et animations (troubles vestibulaires, ou simple préférence).',
|
|
60
|
+
reset: 'Réinitialiser tout',
|
|
61
|
+
},
|
|
62
|
+
en: {
|
|
63
|
+
intro: 'Adjust the display to suit you. Changes apply across the dashboard right away and are remembered for your next sessions.',
|
|
64
|
+
cvd: 'Simulate colour blindness',
|
|
65
|
+
cvdDesc: 'Shows charts through a filter simulating colour-blind perception. A test tool to check your visualisations stay readable.',
|
|
66
|
+
cvdNone: 'None',
|
|
67
|
+
palette: 'Chart palette',
|
|
68
|
+
paletteDesc: 'Default = Tekkare purple palette. CVD-safe = Okabe-Ito, scientifically validated for the 3 types of colour blindness. Charts reload automatically with the new palette.',
|
|
69
|
+
paletteDefault: 'Default Tekkare',
|
|
70
|
+
paletteCvd: 'CVD-safe Okabe-Ito',
|
|
71
|
+
decal: 'Chart patterns',
|
|
72
|
+
decalDesc: 'Adds a pattern (hatching, dots, lines) to each series, on top of colour. Series stay distinct in black and white, or with total colour blindness.',
|
|
73
|
+
contrast: 'High contrast',
|
|
74
|
+
contrastDesc: 'Strengthens text colours, thickens borders, removes transparency.',
|
|
75
|
+
motion: 'Reduce animations',
|
|
76
|
+
motionDesc: 'Turns off every transition and animation (vestibular disorders, or plain preference).',
|
|
77
|
+
reset: 'Reset everything',
|
|
78
|
+
},
|
|
79
|
+
} as const
|
|
80
|
+
|
|
81
|
+
const T = computed(() => WORDS[props.lang] ?? WORDS.fr)
|
|
82
|
+
|
|
83
|
+
/* Les quatre simulations gardent leur nom court, le meme en francais et en
|
|
84
|
+
anglais : ce sont les noms des deficiences, pas des libelles a traduire. */
|
|
85
|
+
const cvdOptions = computed(() => [
|
|
86
|
+
{ value: 'none', label: T.value.cvdNone },
|
|
87
|
+
{ value: 'deutera', label: 'Deutéra' },
|
|
88
|
+
{ value: 'protan', label: 'Protan' },
|
|
89
|
+
{ value: 'tritan', label: 'Tritan' },
|
|
90
|
+
{ value: 'mono', label: 'Mono' },
|
|
91
|
+
])
|
|
92
|
+
|
|
93
|
+
const paletteOptions = computed(() => [
|
|
94
|
+
{ value: 'default', label: T.value.paletteDefault },
|
|
95
|
+
{ value: 'cvdSafe', label: T.value.paletteCvd },
|
|
96
|
+
])
|
|
97
|
+
|
|
98
|
+
/*
|
|
99
|
+
Les refs du composable sont en lecture seule : la commande passe par le
|
|
100
|
+
setter, seul endroit qui pose la classe et enregistre la preference. Les
|
|
101
|
+
proxys inscriptibles ci-dessous laissent quand meme utiliser `v-model`, qui
|
|
102
|
+
est la forme attendue des composants de saisie.
|
|
103
|
+
*/
|
|
104
|
+
const cvdModel = computed({
|
|
105
|
+
get: () => cvd.value as string,
|
|
106
|
+
set: (value: string) => setCvd(value as RmCvdMode),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const paletteModel = computed({
|
|
110
|
+
get: () => chartPalette.value as string,
|
|
111
|
+
set: (value: string) => setChartPalette(value === 'cvdSafe' ? 'cvdSafe' : 'default'),
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const decalModel = computed({
|
|
115
|
+
get: () => chartDecal.value,
|
|
116
|
+
set: setChartDecal,
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const contrastModel = computed({
|
|
120
|
+
get: () => highContrast.value,
|
|
121
|
+
set: setHighContrast,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const motionModel = computed({
|
|
125
|
+
get: () => reduceMotion.value,
|
|
126
|
+
set: setReduceMotion,
|
|
127
|
+
})
|
|
128
|
+
</script>
|
|
129
|
+
|
|
130
|
+
<template>
|
|
131
|
+
<div class="rm-a11y">
|
|
132
|
+
<p class="rm-a11y__intro">{{ description ?? T.intro }}</p>
|
|
133
|
+
|
|
134
|
+
<div class="rm-a11y__row">
|
|
135
|
+
<div class="rm-a11y__head">
|
|
136
|
+
<span class="rm-a11y__label"><i class="ph ph-eye" />{{ T.cvd }}</span>
|
|
137
|
+
</div>
|
|
138
|
+
<p class="rm-a11y__desc">{{ T.cvdDesc }}</p>
|
|
139
|
+
<RmSegmented
|
|
140
|
+
v-model="cvdModel"
|
|
141
|
+
:options="cvdOptions"
|
|
142
|
+
:aria-label="T.cvd"
|
|
143
|
+
variant="soft"
|
|
144
|
+
block
|
|
145
|
+
/>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<div class="rm-a11y__row">
|
|
149
|
+
<div class="rm-a11y__head">
|
|
150
|
+
<span class="rm-a11y__label"><i class="ph ph-palette" />{{ T.palette }}</span>
|
|
151
|
+
</div>
|
|
152
|
+
<p class="rm-a11y__desc">{{ T.paletteDesc }}</p>
|
|
153
|
+
<RmSegmented
|
|
154
|
+
v-model="paletteModel"
|
|
155
|
+
:options="paletteOptions"
|
|
156
|
+
:aria-label="T.palette"
|
|
157
|
+
variant="soft"
|
|
158
|
+
block
|
|
159
|
+
/>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div class="rm-a11y__row">
|
|
163
|
+
<div class="rm-a11y__head">
|
|
164
|
+
<span class="rm-a11y__label"><i class="ph ph-squares-four" />{{ T.decal }}</span>
|
|
165
|
+
<RmToggle v-model="decalModel" :label="T.decal" />
|
|
166
|
+
</div>
|
|
167
|
+
<p class="rm-a11y__desc rm-a11y__desc--last">{{ T.decalDesc }}</p>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<div class="rm-a11y__row">
|
|
171
|
+
<div class="rm-a11y__head">
|
|
172
|
+
<span class="rm-a11y__label"><i class="ph ph-circle-half" />{{ T.contrast }}</span>
|
|
173
|
+
<RmToggle v-model="contrastModel" :label="T.contrast" />
|
|
174
|
+
</div>
|
|
175
|
+
<p class="rm-a11y__desc rm-a11y__desc--last">{{ T.contrastDesc }}</p>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<div class="rm-a11y__row">
|
|
179
|
+
<div class="rm-a11y__head">
|
|
180
|
+
<span class="rm-a11y__label"><i class="ph ph-pause" />{{ T.motion }}</span>
|
|
181
|
+
<RmToggle v-model="motionModel" :label="T.motion" />
|
|
182
|
+
</div>
|
|
183
|
+
<p class="rm-a11y__desc rm-a11y__desc--last">{{ T.motionDesc }}</p>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<button class="rm-a11y__reset" type="button" @click="reset()">
|
|
187
|
+
<i class="ph ph-arrow-counter-clockwise" />{{ T.reset }}
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
</template>
|
|
191
|
+
|
|
192
|
+
<style scoped>
|
|
193
|
+
.rm-a11y{font-family:var(--font-sans);width:100%}.rm-a11y__intro{color:var(--text-secondary);font-size:var(--font-13);line-height:1.5;margin:0 0 18px}.rm-a11y__row{border-bottom:1px solid var(--border-soft);padding:14px 0}.rm-a11y__row:last-of-type{border-bottom:none}.rm-a11y__head{align-items:center;display:flex;gap:12px;justify-content:space-between;margin-bottom:8px}.rm-a11y__label{align-items:center;color:var(--text-primary);display:inline-flex;font-size:var(--font-13);font-weight:600;gap:8px}.rm-a11y__label i{color:var(--color-primary);font-size:16px}.rm-a11y__desc{color:var(--text-muted);font-size:var(--font-12);line-height:1.5;margin:0 0 10px}.rm-a11y__desc--last{margin-bottom:0}.rm-a11y__reset{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-md);color:var(--text-secondary);cursor:pointer;display:inline-flex;font-family:inherit;font-size:var(--font-13);font-weight:600;gap:6px;justify-content:center;margin-top:14px;padding:10px 14px;transition:background var(--duration-fast) var(--ease-in-out),color var(--duration-fast) var(--ease-in-out),border-color var(--duration-fast) var(--ease-in-out);width:100%}.rm-a11y__reset:hover{background:var(--color-danger-bg);border-color:var(--color-danger);color:var(--color-danger)}.rm-a11y__reset:focus-visible{outline:2px solid var(--color-primary);outline-offset:2px}
|
|
194
|
+
</style>
|
|
@@ -5,6 +5,12 @@ import { PhMagnifyingGlass, PhX } from '@phosphor-icons/vue'
|
|
|
5
5
|
export interface SearchSuggestion {
|
|
6
6
|
label: string
|
|
7
7
|
field?: string
|
|
8
|
+
/**
|
|
9
|
+
* Icone Phosphor posee devant la suggestion, a la place de la loupe. Elle
|
|
10
|
+
* dit de quel champ la valeur vient (une marque, une molecule, un
|
|
11
|
+
* laboratoire) : sans elle, dix suggestions se ressemblent toutes.
|
|
12
|
+
*/
|
|
13
|
+
icon?: string
|
|
8
14
|
}
|
|
9
15
|
|
|
10
16
|
const model = defineModel<string>({ default: '' })
|
|
@@ -22,6 +28,22 @@ const props = withDefaults(defineProps<{
|
|
|
22
28
|
haveAdvanced?: boolean
|
|
23
29
|
/** Libelle du lien, pour les pages qui ne sont pas en anglais. */
|
|
24
30
|
advancedLabel?: string
|
|
31
|
+
/**
|
|
32
|
+
* Met en evidence la part saisie dans chaque suggestion. Le composant la
|
|
33
|
+
* decoupe lui-meme a partir du texte tape : la page n'a pas a fabriquer du
|
|
34
|
+
* HTML, et la couleur reste celle du theme.
|
|
35
|
+
*/
|
|
36
|
+
highlightMatch?: boolean
|
|
37
|
+
/**
|
|
38
|
+
* Les suggestions sont en cours de chargement. La liste s'ouvre sur une
|
|
39
|
+
* ligne d'attente plutot que de rester fermee : une recherche qui met une
|
|
40
|
+
* seconde a repondre ne doit pas se lire comme une recherche sans resultat.
|
|
41
|
+
*/
|
|
42
|
+
loading?: boolean
|
|
43
|
+
/** Ligne affichee pendant le chargement des suggestions. */
|
|
44
|
+
loadingText?: string
|
|
45
|
+
/** Ligne affichee quand la recherche ne ramene aucune suggestion. */
|
|
46
|
+
emptyText?: string
|
|
25
47
|
}>(), {
|
|
26
48
|
advancedActive: false,
|
|
27
49
|
advancedCount: 0,
|
|
@@ -29,6 +51,10 @@ const props = withDefaults(defineProps<{
|
|
|
29
51
|
placeholder: 'Search...',
|
|
30
52
|
haveAdvanced: true,
|
|
31
53
|
advancedLabel: 'Advanced search',
|
|
54
|
+
highlightMatch: false,
|
|
55
|
+
loading: false,
|
|
56
|
+
loadingText: 'Searching…',
|
|
57
|
+
emptyText: '',
|
|
32
58
|
})
|
|
33
59
|
|
|
34
60
|
const emit = defineEmits<{
|
|
@@ -72,6 +98,23 @@ function clearSearch() {
|
|
|
72
98
|
emit('search', '')
|
|
73
99
|
}
|
|
74
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Le libelle decoupe autour de la part saisie : avant, la correspondance,
|
|
103
|
+
* apres. Un `v-html` ferait le meme rendu, mais poserait du HTML fabrique par
|
|
104
|
+
* la page dans la liste ; trois morceaux de texte ne le posent pas.
|
|
105
|
+
*/
|
|
106
|
+
function parts(label: string) {
|
|
107
|
+
const query = model.value.trim()
|
|
108
|
+
if (!props.highlightMatch || !query) return [{ text: label, match: false }]
|
|
109
|
+
const at = label.toLowerCase().indexOf(query.toLowerCase())
|
|
110
|
+
if (at < 0) return [{ text: label, match: false }]
|
|
111
|
+
return [
|
|
112
|
+
{ text: label.slice(0, at), match: false },
|
|
113
|
+
{ text: label.slice(at, at + query.length), match: true },
|
|
114
|
+
{ text: label.slice(at + query.length), match: false },
|
|
115
|
+
].filter(part => part.text)
|
|
116
|
+
}
|
|
117
|
+
|
|
75
118
|
function onClickOutside(e: Event) {
|
|
76
119
|
if (barRef.value && !barRef.value.contains(e.target as Node)) {
|
|
77
120
|
showSuggestions.value = false
|
|
@@ -113,22 +156,39 @@ onUnmounted(() => document.removeEventListener('click', onClickOutside))
|
|
|
113
156
|
</div>
|
|
114
157
|
|
|
115
158
|
<Transition name="suggestions">
|
|
116
|
-
<div v-if="showSuggestions && suggestions.length > 0" class="search-bar__suggestions">
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
159
|
+
<div v-if="showSuggestions && (suggestions.length > 0 || loading || emptyText)" class="search-bar__suggestions">
|
|
160
|
+
<p v-if="loading" class="search-bar__suggestion-note">{{ loadingText }}</p>
|
|
161
|
+
<template v-else-if="suggestions.length > 0">
|
|
162
|
+
<button
|
|
163
|
+
v-for="(sugg, i) in suggestions"
|
|
164
|
+
:key="i"
|
|
165
|
+
class="search-bar__suggestion"
|
|
166
|
+
@click="selectSuggestion(sugg)"
|
|
167
|
+
>
|
|
168
|
+
<RmIcon
|
|
169
|
+
v-if="sugg.icon"
|
|
170
|
+
class="search-bar__suggestion-icon"
|
|
171
|
+
:name="sugg.icon"
|
|
172
|
+
:size="14"
|
|
173
|
+
color="var(--text-muted)"
|
|
174
|
+
/>
|
|
175
|
+
<PhMagnifyingGlass v-else :size="14" class="search-bar__suggestion-icon" />
|
|
176
|
+
<span class="search-bar__suggestion-label">
|
|
177
|
+
<span
|
|
178
|
+
v-for="(part, index) in parts(sugg.label)"
|
|
179
|
+
:key="index"
|
|
180
|
+
:class="{ 'search-bar__suggestion-match': part.match }"
|
|
181
|
+
>{{ part.text }}</span>
|
|
182
|
+
</span>
|
|
183
|
+
<span v-if="sugg.field" class="search-bar__suggestion-field">{{ sugg.field.split('.').pop() }}</span>
|
|
184
|
+
</button>
|
|
185
|
+
</template>
|
|
186
|
+
<p v-else class="search-bar__suggestion-note">{{ emptyText }}</p>
|
|
127
187
|
</div>
|
|
128
188
|
</Transition>
|
|
129
189
|
</div>
|
|
130
190
|
</template>
|
|
131
191
|
|
|
132
192
|
<style scoped>
|
|
133
|
-
.search-bar-wrapper{position:relative}.search-bar{align-items:center;border:1px solid var(--border-default);border-radius:var(--radius-pill);display:inline-flex;gap:var(--space-8);height:32px;min-width:200px;padding:var(--space-4) var(--space-12);transition:border-color var(--duration-fast) var(--ease-in-out)}.search-bar:focus-within{border-color:var(--color-secondary);box-shadow:var(--shadow-focus)}.search-bar__icon{color:var(--text-muted);flex-shrink:0}.search-bar__input{background:transparent;border:none;color:var(--text-secondary);font-family:var(--font-sans);font-size:var(--font-14);outline:none;width:100%}.search-bar__input::-moz-placeholder{color:var(--text-muted)}.search-bar__input::placeholder{color:var(--text-muted)}.search-bar__clear{align-items:center;border:none;border-radius:50%;cursor:pointer;display:flex;flex-shrink:0;height:18px;justify-content:center;transition:all var(--duration-fast) var(--ease-in-out);width:18px}.search-bar__clear,.search-bar__clear:hover{background:var(--border-default);color:var(--text-secondary)}.search-bar__advanced{background:none;border:none;border-left:1px solid var(--border-default);color:var(--text-secondary);cursor:pointer;flex-shrink:0;font-family:var(--font-sans);font-size:var(--font-14);padding:0 0 0 var(--space-8);text-decoration:underline;transition:color var(--duration-fast) var(--ease-in-out);white-space:nowrap}.search-bar__advanced:hover{color:var(--color-healthcare)}.search-bar--advanced-active{border-color:var(--color-healthcare)}.search-bar__advanced--active{color:var(--color-healthcare);font-weight:500}.search-bar__advanced-badge{align-items:center;background-color:var(--color-healthcare);border-radius:100px;color:var(--color-text-inverse);display:inline-flex;font-size:10px;font-weight:600;height:16px;justify-content:center;line-height:1;min-width:16px;padding:0 4px}.search-bar__suggestions{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:8px;box-shadow:0 4px 12px rgba(var(--shadow-rgb),.08);left:0;max-height:280px;overflow-y:auto;padding:4px;position:absolute;right:0;top:calc(100% + 4px);z-index:50}.search-bar__suggestion{align-items:center;background:none;border:none;border-radius:4px;color:var(--text-secondary);cursor:pointer;display:flex;font-family:var(--font-sans);font-size:var(--font-14);gap:8px;padding:8px 10px;text-align:left;transition:background-color var(--duration-fast) var(--ease-in-out);width:100%}.search-bar__suggestion:hover{background-color:var(--bg-surface-2)}.search-bar__suggestion-icon{color:var(--text-muted);flex-shrink:0}.search-bar__suggestion-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.search-bar__suggestion-field{background:var(--bg-surface-2);border-radius:3px;color:var(--text-muted);flex-shrink:0;font-size:11px;padding:1px 6px}.suggestions-enter-active,.suggestions-leave-active{transition:opacity var(--duration-fast) var(--ease-in-out),transform var(--duration-fast) var(--ease-in-out)}.suggestions-enter-from,.suggestions-leave-to{opacity:0;transform:translateY(-4px)}
|
|
193
|
+
.search-bar__suggestion-match{color:var(--color-secondary);font-weight:var(--weight-bold)}.search-bar__suggestion-note{color:var(--text-muted);font-size:var(--font-13);margin:0;padding:var(--space-8) var(--space-12)}.search-bar-wrapper{position:relative}.search-bar{align-items:center;border:1px solid var(--border-default);border-radius:var(--radius-pill);display:inline-flex;gap:var(--space-8);height:32px;min-width:200px;padding:var(--space-4) var(--space-12);transition:border-color var(--duration-fast) var(--ease-in-out)}.search-bar:focus-within{border-color:var(--color-secondary);box-shadow:var(--shadow-focus)}.search-bar__icon{color:var(--text-muted);flex-shrink:0}.search-bar__input{background:transparent;border:none;color:var(--text-secondary);font-family:var(--font-sans);font-size:var(--font-14);outline:none;width:100%}.search-bar__input::-moz-placeholder{color:var(--text-muted)}.search-bar__input::placeholder{color:var(--text-muted)}.search-bar__clear{align-items:center;border:none;border-radius:50%;cursor:pointer;display:flex;flex-shrink:0;height:18px;justify-content:center;transition:all var(--duration-fast) var(--ease-in-out);width:18px}.search-bar__clear,.search-bar__clear:hover{background:var(--border-default);color:var(--text-secondary)}.search-bar__advanced{background:none;border:none;border-left:1px solid var(--border-default);color:var(--text-secondary);cursor:pointer;flex-shrink:0;font-family:var(--font-sans);font-size:var(--font-14);padding:0 0 0 var(--space-8);text-decoration:underline;transition:color var(--duration-fast) var(--ease-in-out);white-space:nowrap}.search-bar__advanced:hover{color:var(--color-healthcare)}.search-bar--advanced-active{border-color:var(--color-healthcare)}.search-bar__advanced--active{color:var(--color-healthcare);font-weight:500}.search-bar__advanced-badge{align-items:center;background-color:var(--color-healthcare);border-radius:100px;color:var(--color-text-inverse);display:inline-flex;font-size:10px;font-weight:600;height:16px;justify-content:center;line-height:1;min-width:16px;padding:0 4px}.search-bar__suggestions{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:8px;box-shadow:0 4px 12px rgba(var(--shadow-rgb),.08);left:0;max-height:280px;overflow-y:auto;padding:4px;position:absolute;right:0;top:calc(100% + 4px);z-index:50}.search-bar__suggestion{align-items:center;background:none;border:none;border-radius:4px;color:var(--text-secondary);cursor:pointer;display:flex;font-family:var(--font-sans);font-size:var(--font-14);gap:8px;padding:8px 10px;text-align:left;transition:background-color var(--duration-fast) var(--ease-in-out);width:100%}.search-bar__suggestion:hover{background-color:var(--bg-surface-2)}.search-bar__suggestion-icon{color:var(--text-muted);flex-shrink:0}.search-bar__suggestion-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.search-bar__suggestion-field{background:var(--bg-surface-2);border-radius:3px;color:var(--text-muted);flex-shrink:0;font-size:11px;padding:1px 6px}.suggestions-enter-active,.suggestions-leave-active{transition:opacity var(--duration-fast) var(--ease-in-out),transform var(--duration-fast) var(--ease-in-out)}.suggestions-enter-from,.suggestions-leave-to{opacity:0;transform:translateY(-4px)}
|
|
134
194
|
</style>
|
|
@@ -43,8 +43,14 @@ const props = withDefaults(defineProps<{
|
|
|
43
43
|
* surface, bord arrondi complet, hauteur de rangee) : c'est la forme a
|
|
44
44
|
* utiliser quand le rail se pose SUR la barre, a cote de « Filtres », et non
|
|
45
45
|
* dans le panneau. `default` garde le rail voile des panneaux et des cartes.
|
|
46
|
+
*
|
|
47
|
+
* `soft` inverse le pave : creux clair, pave de surface, libelle actif en
|
|
48
|
+
* violet au lieu de blanc sur violet. C'est la forme des reglages, la ou le
|
|
49
|
+
* rail occupe toute la largeur d'un panneau et se repete d'une rangee a
|
|
50
|
+
* l'autre : un pave plein par rangee ferait une colonne de blocs colores,
|
|
51
|
+
* et l'oeil ne saurait plus lequel est le reglage qu'on vient de changer.
|
|
46
52
|
*/
|
|
47
|
-
variant?: 'default' | 'pill'
|
|
53
|
+
variant?: 'default' | 'pill' | 'soft'
|
|
48
54
|
ariaLabel?: string
|
|
49
55
|
}>(), {
|
|
50
56
|
size: 'md',
|
|
@@ -168,5 +174,5 @@ watch(() => [model.value, items.value.length, props.block], remeasure)
|
|
|
168
174
|
</template>
|
|
169
175
|
|
|
170
176
|
<style scoped>
|
|
171
|
-
.rm-seg{background:color-mix(in srgb,var(--color-primary) 5%,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--pill{background:var(--bg-surface);border-radius:var(--radius-pill);flex-wrap:nowrap;gap:0;overflow-x:auto;padding:2px;scrollbar-width:none}.rm-seg--pill::-webkit-scrollbar{display:none}.rm-seg--pill .rm-seg__option,.rm-seg--pill .rm-seg__thumb{border-radius:var(--radius-pill)}.rm-seg--pill .rm-seg__option{font-size:var(--font-14);height:26px;line-height:26px;padding:0 14px}.rm-seg--pill.rm-seg--sm .rm-seg__option{font-size:var(--font-13);height:24px;line-height:24px;padding:0 12px}.rm-seg--pill .rm-seg__option--sep:before{left:0}.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}}
|
|
177
|
+
.rm-seg{background:color-mix(in srgb,var(--color-primary) 5%,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--pill{background:var(--bg-surface);border-radius:var(--radius-pill);flex-wrap:nowrap;gap:0;overflow-x:auto;padding:2px;scrollbar-width:none}.rm-seg--pill::-webkit-scrollbar{display:none}.rm-seg--pill .rm-seg__option,.rm-seg--pill .rm-seg__thumb{border-radius:var(--radius-pill)}.rm-seg--pill .rm-seg__option{font-size:var(--font-14);height:26px;line-height:26px;padding:0 14px}.rm-seg--pill.rm-seg--sm .rm-seg__option{font-size:var(--font-13);height:24px;line-height:24px;padding:0 12px}.rm-seg--soft{background:var(--bg-surface-3);border-radius:var(--radius-pill);box-shadow:inset 0 1px 2px rgba(30,41,59,.04);gap:0;padding:3px}.rm-seg--soft .rm-seg__thumb{background:var(--bg-surface);border-radius:var(--radius-pill);box-shadow:var(--shadow-sm)}.rm-seg--soft .rm-seg__option{border-radius:var(--radius-pill);color:var(--text-muted);font-size:var(--font-12);padding:7px 10px}.rm-seg--soft .rm-seg__option:hover:not(:disabled):not(.rm-seg__option--active){color:var(--text-primary)}.rm-seg--soft .rm-seg__option--active{color:var(--color-secondary);font-weight:600}.rm-seg--soft .rm-seg__option--sep:before{display:none}.rm-seg--pill .rm-seg__option--sep:before{left:0}.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}}
|
|
172
178
|
</style>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmToggle - bascule simple, allumee ou eteinte.
|
|
3
|
+
|
|
4
|
+
Reprend le `.a11y-switch` du design system : une pastille de 40x22 posee au
|
|
5
|
+
bout d'une rangee de reglage, dont le libelle est le texte de la rangee.
|
|
6
|
+
|
|
7
|
+
A ne pas confondre avec `RmSwitch`, qui encadre la bascule de deux intitules
|
|
8
|
+
parce que ses deux etats ont un nom (« Volumes » / « Valeurs »). Ici un seul
|
|
9
|
+
etat est nomme, l'autre est son absence : « Contraste eleve » est actif ou
|
|
10
|
+
ne l'est pas, et lui inventer un intitule contraire (« Contraste normal »)
|
|
11
|
+
ferait lire deux reglages la ou il n'y en a qu'un.
|
|
12
|
+
|
|
13
|
+
Le libelle n'est pas rendu par le composant : dans le panneau il vit dans
|
|
14
|
+
l'en-tete de la rangee, avec son icone, et le rappeler ici le dirait deux
|
|
15
|
+
fois a un lecteur d'ecran. `label` sert donc de nom accessible seul, et il
|
|
16
|
+
est obligatoire : une bascule sans nom ne s'annonce pas.
|
|
17
|
+
-->
|
|
18
|
+
<script setup lang="ts">
|
|
19
|
+
withDefaults(defineProps<{
|
|
20
|
+
/** Nom accessible de la bascule. Non affiche. */
|
|
21
|
+
label: string
|
|
22
|
+
disabled?: boolean
|
|
23
|
+
}>(), {
|
|
24
|
+
disabled: false,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const on = defineModel<boolean>({ default: false })
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<button
|
|
32
|
+
class="rm-toggle"
|
|
33
|
+
:class="{ 'rm-toggle--on': on }"
|
|
34
|
+
type="button"
|
|
35
|
+
role="switch"
|
|
36
|
+
:aria-checked="on"
|
|
37
|
+
:aria-label="label"
|
|
38
|
+
:disabled="disabled"
|
|
39
|
+
@click="on = !on"
|
|
40
|
+
>
|
|
41
|
+
<span class="rm-toggle__knob" aria-hidden="true" />
|
|
42
|
+
</button>
|
|
43
|
+
</template>
|
|
44
|
+
|
|
45
|
+
<style scoped>
|
|
46
|
+
.rm-toggle{background:var(--border-default);border:none;border-radius:var(--radius-pill);cursor:pointer;flex-shrink:0;height:22px;padding:0;position:relative;transition:background var(--duration-fast) var(--ease-in-out);width:40px}.rm-toggle--on{background:var(--color-primary)}.rm-toggle:disabled{cursor:not-allowed;opacity:.6}.rm-toggle:focus-visible{outline:2px solid var(--color-primary);outline-offset:2px}.rm-toggle__knob{background:var(--bg-surface);border-radius:50%;box-shadow:0 1px 3px rgba(0,0,0,.15);height:18px;left:2px;position:absolute;top:2px;transition:transform var(--duration-fast) var(--ease-in-out);width:18px}.rm-toggle--on .rm-toggle__knob{transform:translateX(18px)}@media (prefers-reduced-motion:reduce){.rm-toggle,.rm-toggle__knob{transition:none}}
|
|
47
|
+
</style>
|
|
@@ -2,10 +2,19 @@ export type RmCvdMode = 'none' | 'deutera' | 'protan' | 'tritan' | 'mono';
|
|
|
2
2
|
export type RmChartPalette = 'default' | 'cvdSafe';
|
|
3
3
|
export type RmTextScale = 'normal' | 'large' | 'xl';
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Pose sur `body` les classes enregistrees au dernier passage.
|
|
6
|
+
*
|
|
7
|
+
* Appelee par le plugin de demarrage, avant le premier rendu. Le composable
|
|
8
|
+
* ne peut pas s'en charger : il ne lit `body` qu'au montage d'un composant,
|
|
9
|
+
* et l'ecran s'afficherait donc en clair, en taille standard, sans contraste
|
|
10
|
+
* renforce, avant de basculer — exactement le saut que ces reglages sont la
|
|
11
|
+
* pour eviter, et le plus penible pour qui a besoin du contraste.
|
|
12
|
+
*
|
|
13
|
+
* Ne passe pas par les setters a dessein : la garde de transitions n'a rien a
|
|
14
|
+
* neutraliser avant le premier rendu, et reecrire les preferences qu'on vient
|
|
15
|
+
* de lire n'apporte rien.
|
|
7
16
|
*/
|
|
8
|
-
export declare function
|
|
17
|
+
export declare function rmApplySavedPrefs(): void;
|
|
9
18
|
export declare function useRomulusTheme(): {
|
|
10
19
|
night: Readonly<import("vue").Ref<boolean, boolean>>;
|
|
11
20
|
highContrast: Readonly<import("vue").Ref<boolean, boolean>>;
|
|
@@ -24,11 +24,23 @@ function writePref(key, value) {
|
|
|
24
24
|
} catch {
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
-
export function rmSavedNight() {
|
|
28
|
-
return readPrefs().night === true;
|
|
29
|
-
}
|
|
30
27
|
const PALETTE_CLASS = "pal-cvd-safe";
|
|
31
28
|
const DECAL_CLASS = "chart-decal";
|
|
29
|
+
export function rmApplySavedPrefs() {
|
|
30
|
+
const prefs = readPrefs();
|
|
31
|
+
const { classList } = document.body;
|
|
32
|
+
const add = (value, allowed) => {
|
|
33
|
+
if (typeof value === "string" && allowed.includes(value)) classList.add(value);
|
|
34
|
+
};
|
|
35
|
+
classList.toggle("night", prefs.night === true);
|
|
36
|
+
classList.toggle("high-contrast", prefs["high-contrast"] === true);
|
|
37
|
+
classList.toggle("reduce-motion", prefs["reduce-motion"] === true);
|
|
38
|
+
classList.toggle(DECAL_CLASS, prefs.decal === true);
|
|
39
|
+
classList.toggle(PALETTE_CLASS, prefs.palette === "pal-cvdSafe");
|
|
40
|
+
classList.toggle("density-compact", prefs.density === "density-compact");
|
|
41
|
+
add(prefs.text, Object.values(TEXT_CLASSES));
|
|
42
|
+
add(prefs.cvd, Object.values(CVD_CLASSES));
|
|
43
|
+
}
|
|
32
44
|
export function useRomulusTheme() {
|
|
33
45
|
const night = ref(false);
|
|
34
46
|
const highContrast = ref(false);
|
|
@@ -47,34 +59,41 @@ export function useRomulusTheme() {
|
|
|
47
59
|
const setHighContrast = (value) => apply(() => {
|
|
48
60
|
highContrast.value = value;
|
|
49
61
|
document.body.classList.toggle("high-contrast", value);
|
|
62
|
+
writePref("high-contrast", value);
|
|
50
63
|
});
|
|
51
64
|
const setReduceMotion = (value) => apply(() => {
|
|
52
65
|
reduceMotion.value = value;
|
|
53
66
|
document.body.classList.toggle("reduce-motion", value);
|
|
67
|
+
writePref("reduce-motion", value);
|
|
54
68
|
});
|
|
55
69
|
const setCompact = (value) => apply(() => {
|
|
56
70
|
compact.value = value;
|
|
57
71
|
document.body.classList.toggle("density-compact", value);
|
|
72
|
+
writePref("density", value ? "density-compact" : "density-default");
|
|
58
73
|
});
|
|
59
74
|
const setCvd = (mode) => apply(() => {
|
|
60
75
|
cvd.value = mode;
|
|
61
76
|
const { body } = document;
|
|
62
77
|
Object.values(CVD_CLASSES).forEach((c) => body.classList.remove(c));
|
|
63
78
|
if (mode !== "none") body.classList.add(CVD_CLASSES[mode]);
|
|
79
|
+
writePref("cvd", mode === "none" ? "cvd-none" : CVD_CLASSES[mode]);
|
|
64
80
|
});
|
|
65
81
|
const setTextScale = (scale) => apply(() => {
|
|
66
82
|
textScale.value = scale;
|
|
67
83
|
const { body } = document;
|
|
68
84
|
Object.values(TEXT_CLASSES).forEach((c) => body.classList.remove(c));
|
|
69
85
|
if (scale !== "normal") body.classList.add(TEXT_CLASSES[scale]);
|
|
86
|
+
writePref("text", scale === "normal" ? "text-default" : TEXT_CLASSES[scale]);
|
|
70
87
|
});
|
|
71
88
|
const setChartPalette = (palette) => apply(() => {
|
|
72
89
|
chartPalette.value = palette;
|
|
73
90
|
document.body.classList.toggle(PALETTE_CLASS, palette === "cvdSafe");
|
|
91
|
+
writePref("palette", palette === "cvdSafe" ? "pal-cvdSafe" : "pal-default");
|
|
74
92
|
});
|
|
75
93
|
const setChartDecal = (value) => apply(() => {
|
|
76
94
|
chartDecal.value = value;
|
|
77
95
|
document.body.classList.toggle(DECAL_CLASS, value);
|
|
96
|
+
writePref("decal", value);
|
|
78
97
|
});
|
|
79
98
|
const reset = () => apply(() => {
|
|
80
99
|
const { body } = document;
|
|
@@ -86,7 +105,6 @@ export function useRomulusTheme() {
|
|
|
86
105
|
textScale.value = "normal";
|
|
87
106
|
chartPalette.value = "default";
|
|
88
107
|
chartDecal.value = false;
|
|
89
|
-
writePref("night", false);
|
|
90
108
|
body.classList.remove(
|
|
91
109
|
"night",
|
|
92
110
|
"high-contrast",
|
|
@@ -97,6 +115,10 @@ export function useRomulusTheme() {
|
|
|
97
115
|
...Object.values(CVD_CLASSES),
|
|
98
116
|
...Object.values(TEXT_CLASSES)
|
|
99
117
|
);
|
|
118
|
+
try {
|
|
119
|
+
localStorage.removeItem(STORE_KEY);
|
|
120
|
+
} catch {
|
|
121
|
+
}
|
|
100
122
|
});
|
|
101
123
|
onMounted(() => {
|
|
102
124
|
const list = document.body.classList;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineNuxtPlugin } from "#app";
|
|
2
|
-
import {
|
|
2
|
+
import { rmApplySavedPrefs } from "../composables/useTheme.js";
|
|
3
3
|
export default defineNuxtPlugin(() => {
|
|
4
|
-
|
|
4
|
+
rmApplySavedPrefs();
|
|
5
5
|
});
|