@dnax/ui 0.0.1

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.
Files changed (150) hide show
  1. package/README.md +15 -0
  2. package/components/QAccordion.vue +68 -0
  3. package/components/QAccordionContent.vue +62 -0
  4. package/components/QAccordionItem.vue +24 -0
  5. package/components/QAccordionTrigger.vue +51 -0
  6. package/components/QActionSheet.vue +159 -0
  7. package/components/QApp.vue +8 -0
  8. package/components/QAutocomplete.vue +316 -0
  9. package/components/QAvatar.vue +92 -0
  10. package/components/QBackHeader.vue +69 -0
  11. package/components/QBackTop.vue +125 -0
  12. package/components/QBadge.vue +55 -0
  13. package/components/QBottomSheet.vue +177 -0
  14. package/components/QBottomSheetFooter.vue +9 -0
  15. package/components/QBottomSheetHeader.vue +35 -0
  16. package/components/QBottomSheetProvider.vue +62 -0
  17. package/components/QBottomSheetTrigger.vue +18 -0
  18. package/components/QBtn.vue +144 -0
  19. package/components/QBtnGroup.vue +31 -0
  20. package/components/QBubble.vue +36 -0
  21. package/components/QBubbleContent.vue +28 -0
  22. package/components/QBubbleGroup.vue +25 -0
  23. package/components/QBubbleReactions.vue +40 -0
  24. package/components/QCard.vue +63 -0
  25. package/components/QCardActions.vue +27 -0
  26. package/components/QCardContent.vue +29 -0
  27. package/components/QCarousel.vue +115 -0
  28. package/components/QCarouselContent.vue +23 -0
  29. package/components/QCarouselItem.vue +20 -0
  30. package/components/QCarouselNav.vue +51 -0
  31. package/components/QCheckbox.vue +146 -0
  32. package/components/QChip.vue +91 -0
  33. package/components/QCircularProgress.vue +122 -0
  34. package/components/QCol.vue +52 -0
  35. package/components/QCollapse.vue +141 -0
  36. package/components/QConfigProvider.vue +164 -0
  37. package/components/QContainer.vue +201 -0
  38. package/components/QCountDown.vue +164 -0
  39. package/components/QCountryPicker.vue +593 -0
  40. package/components/QDataGrid.vue +491 -0
  41. package/components/QDatePicker.vue +250 -0
  42. package/components/QDialog.vue +163 -0
  43. package/components/QDialogFooter.vue +9 -0
  44. package/components/QDialogHeader.vue +41 -0
  45. package/components/QDialogProvider.vue +65 -0
  46. package/components/QDialogTrigger.vue +18 -0
  47. package/components/QFab.vue +80 -0
  48. package/components/QFabAction.vue +48 -0
  49. package/components/QFilePicker.vue +294 -0
  50. package/components/QFooter.vue +72 -0
  51. package/components/QGallery.vue +280 -0
  52. package/components/QGrid.vue +54 -0
  53. package/components/QHeader.vue +38 -0
  54. package/components/QIcon.vue +53 -0
  55. package/components/QImagePicker.vue +248 -0
  56. package/components/QImagePreview.vue +225 -0
  57. package/components/QImagePreviewProvider.vue +35 -0
  58. package/components/QImg.vue +142 -0
  59. package/components/QInfiniteScroll.vue +109 -0
  60. package/components/QInnerLoading.vue +67 -0
  61. package/components/QInput.vue +289 -0
  62. package/components/QInputOtp.vue +238 -0
  63. package/components/QInputPassword.vue +67 -0
  64. package/components/QInputTag.vue +281 -0
  65. package/components/QIntersection.vue +119 -0
  66. package/components/QItem.vue +60 -0
  67. package/components/QItemSection.vue +47 -0
  68. package/components/QLinearProgress.vue +94 -0
  69. package/components/QList.vue +39 -0
  70. package/components/QLoading.vue +77 -0
  71. package/components/QLoadingProvider.vue +41 -0
  72. package/components/QMarquee.vue +56 -0
  73. package/components/QMessageScroller.vue +10 -0
  74. package/components/QMessageScrollerButton.vue +48 -0
  75. package/components/QMessageScrollerContent.vue +31 -0
  76. package/components/QMessageScrollerItem.vue +49 -0
  77. package/components/QMessageScrollerProvider.vue +198 -0
  78. package/components/QMessageScrollerViewport.vue +109 -0
  79. package/components/QNavMenu.vue +75 -0
  80. package/components/QNavMenuContent.vue +10 -0
  81. package/components/QNavMenuItem.vue +60 -0
  82. package/components/QNavMenuTrigger.vue +96 -0
  83. package/components/QNotifyProvider.vue +33 -0
  84. package/components/QNotifyToast.vue +60 -0
  85. package/components/QPage.vue +56 -0
  86. package/components/QPagination.vue +195 -0
  87. package/components/QParallax.vue +124 -0
  88. package/components/QPullToRefresh.vue +138 -0
  89. package/components/QRadio.vue +99 -0
  90. package/components/QRating.vue +105 -0
  91. package/components/QReorder.vue +191 -0
  92. package/components/QRollingText.vue +225 -0
  93. package/components/QRow.vue +23 -0
  94. package/components/QSafeArea.vue +39 -0
  95. package/components/QScrollArea.vue +118 -0
  96. package/components/QSelect.vue +698 -0
  97. package/components/QSeparator.vue +39 -0
  98. package/components/QSidebar.vue +153 -0
  99. package/components/QSidebarContent.vue +14 -0
  100. package/components/QSidebarFooter.vue +14 -0
  101. package/components/QSidebarHeader.vue +14 -0
  102. package/components/QSidebarMenu.vue +9 -0
  103. package/components/QSidebarMenuButton.vue +59 -0
  104. package/components/QSidebarMenuItem.vue +9 -0
  105. package/components/QSidebarTrigger.vue +32 -0
  106. package/components/QSkeleton.vue +95 -0
  107. package/components/QSlider.vue +205 -0
  108. package/components/QSpace.vue +10 -0
  109. package/components/QSpinner.vue +172 -0
  110. package/components/QSplitter.vue +145 -0
  111. package/components/QSticky.vue +181 -0
  112. package/components/QSwipeCell.vue +217 -0
  113. package/components/QSwiper.vue +102 -0
  114. package/components/QSwiperSlide.vue +19 -0
  115. package/components/QSyntax.vue +171 -0
  116. package/components/QTab.vue +175 -0
  117. package/components/QTabPanel.vue +50 -0
  118. package/components/QTabPanels.vue +106 -0
  119. package/components/QTable.vue +575 -0
  120. package/components/QTabs.vue +188 -0
  121. package/components/QText.vue +316 -0
  122. package/components/QTextCaption.vue +122 -0
  123. package/components/QToolbar.vue +28 -0
  124. package/components/QTooltip.vue +313 -0
  125. package/components/QUploader.vue +473 -0
  126. package/components/QVideo.vue +289 -0
  127. package/components/QVirtualScroll.vue +190 -0
  128. package/components/internal/QDateCalendar.vue +153 -0
  129. package/index.ts +151 -0
  130. package/lib/breakpoints.ts +44 -0
  131. package/lib/colors.ts +24 -0
  132. package/lib/config.ts +34 -0
  133. package/lib/countries.ts +261 -0
  134. package/lib/formula.ts +630 -0
  135. package/lib/icons.ts +38 -0
  136. package/lib/loading.ts +75 -0
  137. package/lib/overlayBack.ts +124 -0
  138. package/lib/platform.ts +146 -0
  139. package/lib/primitive.ts +25 -0
  140. package/lib/q.ts +287 -0
  141. package/lib/screen.ts +105 -0
  142. package/lib/search.ts +35 -0
  143. package/lib/themeVars.ts +22 -0
  144. package/lib/useComponentProps.ts +54 -0
  145. package/lib/utils.ts +7 -0
  146. package/module.ts +89 -0
  147. package/package.json +41 -0
  148. package/scripts/generate-exports.ts +56 -0
  149. package/styles/main.css +7627 -0
  150. package/tsconfig.json +33 -0
@@ -0,0 +1,142 @@
1
+ <script setup lang="ts">
2
+ // QImg — API Quasar : <q-img src="…" ratio="16/9" contain position="50% 50%" spinner-color loading />
3
+ // Slots : default (overlay), loading (spinner par défaut), error
4
+ // Placeholder : placeholder-src (image basse résolution affichée pendant le chargement)
5
+ import { computed, ref, watch } from "vue"
6
+ import { cn } from "../lib/utils"
7
+ import { colorValue } from "../lib/colors"
8
+ import { radiusStyle, useRadius } from "../lib/useComponentProps"
9
+ import type { RadiusProp } from "../lib/useComponentProps"
10
+
11
+ interface Props {
12
+ src?: string
13
+ alt?: string
14
+ /** Ratio d'affichage ("16/9", "4/3", "1", nombre) */
15
+ ratio?: string | number
16
+ /** object-fit: contain (sinon cover) */
17
+ contain?: boolean
18
+ /** Position de l'image ("50% 50%") */
19
+ position?: string
20
+ /** Force l'état chargement (spinner) */
21
+ loading?: boolean
22
+ spinnerColor?: string
23
+ spinnerSize?: string
24
+ /** Image placeholder (basse résolution) affichée pendant le chargement */
25
+ placeholderSrc?: string
26
+ /** Coins arrondis : true = pilule, ou échelle xs|sm|md|lg (none = carré) */
27
+ radius?: RadiusProp
28
+ imgClass?: string
29
+ imgStyle?: string
30
+ }
31
+
32
+ const props = withDefaults(defineProps<Props>(), {
33
+ position: "50% 50%",
34
+ loading: false,
35
+ spinnerColor: "",
36
+ spinnerSize: "",
37
+ placeholderSrc: "",
38
+ imgClass: "",
39
+ imgStyle: "",
40
+ })
41
+
42
+ const emit = defineEmits<{ load: [event: Event]; error: [event: Event] }>()
43
+
44
+ const imgLoading = ref(false)
45
+ const imgError = ref(false)
46
+
47
+ // Dès que src change : retour en état chargement (le @load remettra à false)
48
+ watch(
49
+ () => props.src,
50
+ (s) => {
51
+ imgLoading.value = !!s
52
+ imgError.value = false
53
+ },
54
+ { immediate: true },
55
+ )
56
+
57
+ const containerStyle = computed<Record<string, string>>(() => {
58
+ const style: Record<string, string> = {}
59
+ if (props.ratio !== undefined) {
60
+ style.aspectRatio = String(props.ratio).replace("/", " / ")
61
+ }
62
+ return style
63
+ })
64
+
65
+ const spinnerStyle = computed<Record<string, string>>(() => {
66
+ const style: Record<string, string> = {}
67
+ if (props.spinnerColor) style.color = colorValue(props.spinnerColor)
68
+ if (props.spinnerSize) {
69
+ style.width = props.spinnerSize
70
+ style.height = props.spinnerSize
71
+ }
72
+ return style
73
+ })
74
+
75
+ const imageClasses = computed(() =>
76
+ cn("q-img__image", props.contain && "q-img__image--contain", props.imgClass),
77
+ )
78
+
79
+ // radius : prop explicite > composantProps.QImg > composantProps.default → --q-radius
80
+ const effectiveRadius = useRadius("QImg", () => props.radius)
81
+ const roundedStyle = computed(() => radiusStyle(effectiveRadius.value))
82
+
83
+ const imageStyle = computed(() => `${props.imgStyle}; object-position: ${props.position}`)
84
+
85
+ // L'image est prête (chargée) → fondu
86
+ const ready = computed(() => !imgLoading.value && !imgError.value)
87
+
88
+ const onLoad = (e: Event) => {
89
+ imgLoading.value = false
90
+ imgError.value = false
91
+ emit("load", e)
92
+ }
93
+
94
+ const onError = (e: Event) => {
95
+ imgLoading.value = false
96
+ imgError.value = true
97
+ emit("error", e)
98
+ }
99
+
100
+ const showLoading = computed(() => props.loading || imgLoading.value)
101
+ </script>
102
+
103
+ <template>
104
+ <div class="q-img" :style="[containerStyle, roundedStyle]">
105
+ <!-- Placeholder basse résolution pendant le chargement -->
106
+ <img
107
+ v-if="placeholderSrc && imgLoading && !imgError"
108
+ :src="placeholderSrc"
109
+ alt=""
110
+ aria-hidden="true"
111
+ class="q-img__placeholder"
112
+ :class="props.contain && 'q-img__image--contain'"
113
+ />
114
+
115
+ <img
116
+ v-if="src"
117
+ :src="src"
118
+ :alt="alt"
119
+ class="q-img__image"
120
+ :class="[imageClasses, ready && 'q-img__image--ready']"
121
+ :style="imageStyle"
122
+ loading="lazy"
123
+ @load="onLoad"
124
+ @error="onError"
125
+ />
126
+
127
+ <!-- Loading / erreur -->
128
+ <div v-if="showLoading" class="q-img__state">
129
+ <slot name="loading">
130
+ <span class="q-spinner-ring" :style="spinnerStyle" aria-hidden="true" />
131
+ </slot>
132
+ </div>
133
+ <div v-else-if="imgError" class="q-img__state">
134
+ <slot name="error" />
135
+ </div>
136
+
137
+ <!-- Overlay -->
138
+ <div class="q-img__overlay">
139
+ <slot />
140
+ </div>
141
+ </div>
142
+ </template>
@@ -0,0 +1,109 @@
1
+ <script setup lang="ts">
2
+ // QInfiniteScroll — API Quasar : <q-infinite-scroll :offset="250" @load="onLoad" animated>
3
+ // Charge plus de contenu quand on approche du bas (scroll fenêtre ou conteneur scrollable).
4
+ // Le handler reçoit (index, done) — appeler done() quand le chargement est fini.
5
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue"
6
+
7
+ interface Props {
8
+ /** Distance (px) du bas avant de déclencher le chargement (défaut : 250) */
9
+ offset?: number
10
+ /** Désactive le chargement */
11
+ disable?: boolean
12
+ /** Anime l'arrivée des nouveaux items (fondu + glissement vers le haut) */
13
+ animated?: boolean
14
+ /** Masque l'indicateur de chargement (scroll silencieux — TikTok/Instagram) */
15
+ hideLoading?: boolean
16
+ }
17
+
18
+ const props = withDefaults(defineProps<Props>(), {
19
+ offset: 250,
20
+ disable: false,
21
+ animated: false,
22
+ hideLoading: false,
23
+ })
24
+
25
+ const emit = defineEmits<{
26
+ load: [index: number, done: () => void]
27
+ }>()
28
+
29
+ const rootEl = ref<HTMLElement | null>(null)
30
+ const loading = ref(false)
31
+ let loadIndex = 0
32
+
33
+ // Conteneur scrollable le plus proche (ou la fenêtre)
34
+ const scrollContainer = ref<HTMLElement | Window | null>(null)
35
+
36
+ const findScrollContainer = (el: HTMLElement | null): HTMLElement | Window | null => {
37
+ let node: HTMLElement | null = el?.parentElement ?? null
38
+ while (node) {
39
+ if (/(auto|scroll)/.test(getComputedStyle(node).overflowY)) return node
40
+ node = node.parentElement
41
+ }
42
+ return typeof window !== "undefined" ? window : null
43
+ }
44
+
45
+ const load = () => {
46
+ if (props.disable || loading.value) return
47
+ loading.value = true
48
+ loadIndex += 1
49
+ emit("load", loadIndex, () => {
50
+ loading.value = false
51
+ nextTick(check)
52
+ })
53
+ }
54
+
55
+ const check = () => {
56
+ if (props.disable || loading.value) return
57
+ const el = rootEl.value
58
+ const sc = scrollContainer.value
59
+ if (!el || !sc) return
60
+ const rect = el.getBoundingClientRect()
61
+ if (sc === window) {
62
+ if (rect.bottom <= window.innerHeight + props.offset) load()
63
+ }
64
+ else {
65
+ const scRect = (sc as HTMLElement).getBoundingClientRect()
66
+ if (rect.bottom <= scRect.bottom + props.offset) load()
67
+ }
68
+ }
69
+
70
+ let resizeObserver: ResizeObserver | undefined
71
+
72
+ onMounted(() => {
73
+ scrollContainer.value = findScrollContainer(rootEl.value)
74
+ if (typeof window !== "undefined") {
75
+ // capture : capte aussi le scroll des conteneurs internes
76
+ window.addEventListener("scroll", check, true)
77
+ window.addEventListener("resize", check)
78
+ if (typeof ResizeObserver !== "undefined") {
79
+ resizeObserver = new ResizeObserver(check)
80
+ if (rootEl.value) resizeObserver.observe(rootEl.value)
81
+ }
82
+ }
83
+ check()
84
+ })
85
+
86
+ onBeforeUnmount(() => {
87
+ if (typeof window !== "undefined") {
88
+ window.removeEventListener("scroll", check, true)
89
+ window.removeEventListener("resize", check)
90
+ resizeObserver?.disconnect()
91
+ }
92
+ })
93
+
94
+ const classes = computed(() => ({
95
+ "q-infinite-scroll--animated": props.animated,
96
+ }))
97
+ </script>
98
+
99
+ <template>
100
+ <div ref="rootEl" class="q-infinite-scroll" :class="classes">
101
+ <slot />
102
+ <div v-if="loading && !hideLoading" class="q-infinite-scroll__loading" role="status" aria-live="polite">
103
+ <slot name="loading">
104
+ <span class="q-spinner-ring" aria-hidden="true" />
105
+ <span class="q-infinite-scroll__loading-label">Loading more…</span>
106
+ </slot>
107
+ </div>
108
+ </div>
109
+ </template>
@@ -0,0 +1,67 @@
1
+ <script setup lang="ts">
2
+ // QInnerLoading — overlay de chargement dans un conteneur : <q-inner-loading showing label="…" color="primary" />
3
+ // Le parent doit être position: relative. Rendu en fondu (Transition).
4
+ import { computed } from "vue"
5
+ import { Icon } from "@iconify/vue"
6
+ import { cn } from "../lib/utils"
7
+ import { colorValue } from "../lib/colors"
8
+
9
+ const SIZE_MAP: Record<string, string> = { sm: "20px", md: "32px", lg: "48px" }
10
+
11
+ interface Props {
12
+ /** Affiche/masque le loading */
13
+ showing?: boolean
14
+ /** Libellé sous le spinner */
15
+ label?: string
16
+ /** Taille : sm | md | lg ou valeur CSS */
17
+ size?: string
18
+ /** Couleur du spinner (token ou hex) */
19
+ color?: string
20
+ /** Icône Iconify en rotation (sinon spinner CSS) */
21
+ icon?: string
22
+ /** Fond sombre */
23
+ dark?: boolean
24
+ }
25
+
26
+ const props = withDefaults(defineProps<Props>(), {
27
+ showing: false,
28
+ size: "md",
29
+ color: "primary",
30
+ icon: "",
31
+ dark: false,
32
+ })
33
+
34
+ const spinnerStyle = computed<Record<string, string>>(() => {
35
+ const style: Record<string, string> = {}
36
+ style.width = SIZE_MAP[props.size] ?? props.size
37
+ style.height = SIZE_MAP[props.size] ?? props.size
38
+ style.color = colorValue(props.color)
39
+ return style
40
+ })
41
+
42
+ const overlayClasses = computed(() => cn("q-inner-loading", props.dark && "q-inner-loading--dark"))
43
+ </script>
44
+
45
+ <template>
46
+ <Transition name="q-inner-loading">
47
+ <div
48
+ v-if="showing"
49
+ class="q-inner-loading"
50
+ :class="overlayClasses"
51
+ role="status"
52
+ aria-live="polite"
53
+ >
54
+ <slot>
55
+ <Icon
56
+ v-if="icon"
57
+ :icon="icon"
58
+ class="q-inner-loading__icon"
59
+ :style="spinnerStyle"
60
+ aria-hidden="true"
61
+ />
62
+ <span v-else class="q-spinner-ring" :style="spinnerStyle" aria-hidden="true" />
63
+ <span v-if="label" class="q-inner-loading__label">{{ label }}</span>
64
+ </slot>
65
+ </div>
66
+ </Transition>
67
+ </template>
@@ -0,0 +1,289 @@
1
+ <script setup lang="ts">
2
+ // QInput — API Quasar : <q-input v-model="…" label="Email" outlined dense clearable counter maxlength="60" hint error error-message />
3
+ // Variants : outlined (défaut) | filled | borderless ; le label est toujours affiché au-dessus du champ.
4
+ import { computed, nextTick, ref, watch } from "vue"
5
+ import { Icon } from "@iconify/vue"
6
+ import { icons } from "../lib/icons"
7
+ import { cn } from "../lib/utils"
8
+ import { radiusStyle, useRadius } from "../lib/useComponentProps"
9
+ import type { RadiusProp } from "../lib/useComponentProps"
10
+
11
+ interface Props {
12
+ modelValue?: string | number | null
13
+ /** Type de champ (text par défaut) */
14
+ type?: "text" | "password" | "number" | "email" | "search" | "tel" | "url"
15
+ /** Label affiché au-dessus du champ */
16
+ label?: string
17
+ /** @deprecated Le label est toujours affiché au-dessus — cette prop est ignorée */
18
+ stackLabel?: boolean
19
+ /** Aide affichée sous le champ */
20
+ hint?: string
21
+ /** État d'erreur */
22
+ error?: boolean
23
+ /** Message d'erreur (affiché quand error) */
24
+ errorMessage?: string
25
+ /** Affiche le compteur (nécessite maxlength) */
26
+ counter?: boolean
27
+ maxlength?: number
28
+ /** Préfixe dans le champ */
29
+ prefix?: string
30
+ /** Suffixe dans le champ */
31
+ suffix?: string
32
+ /** Icône Iconify à gauche (remplacée par le slot #prepend s'il est fourni) */
33
+ iconLeft?: string
34
+ /** Icône Iconify à droite (remplacée par le slot #append s'il est fourni) */
35
+ iconRight?: string
36
+ /** Masque de saisie : # = chiffre, A = lettre, N = alphanumérique, X = quelconque (ex. "##-##-##") */
37
+ mask?: string
38
+ /** Remplit les emplacements vides par "_" (défaut : la saisie s'arrête au dernier caractère) */
39
+ fillMask?: boolean
40
+ /** Émet la valeur sans les caractères du masque (littéraux et _) */
41
+ unmaskedValue?: boolean
42
+ /** Bouton d'effacement */
43
+ clearable?: boolean
44
+ /** Bordure visible */
45
+ outlined?: boolean
46
+ /** Fond gris, soulignement */
47
+ filled?: boolean
48
+ /** Aucune bordure */
49
+ borderless?: boolean
50
+ /** Coins arrondis : true = pilule, ou échelle xs|sm|md|lg (none = carré) */
51
+ radius?: RadiusProp
52
+ /** Hauteur réduite */
53
+ dense?: boolean
54
+ /** Textarea à hauteur auto */
55
+ autogrow?: boolean
56
+ disable?: boolean
57
+ readonly?: boolean
58
+ placeholder?: string
59
+ }
60
+
61
+ const props = withDefaults(defineProps<Props>(), {
62
+ type: "text",
63
+ stackLabel: false,
64
+ error: false,
65
+ counter: false,
66
+ clearable: false,
67
+ outlined: false,
68
+ filled: false,
69
+ borderless: false,
70
+ dense: false,
71
+ autogrow: false,
72
+ disable: false,
73
+ readonly: false,
74
+ fillMask: false,
75
+ unmaskedValue: false,
76
+ })
77
+
78
+ const emit = defineEmits<{
79
+ "update:modelValue": [value: string]
80
+ focus: [event: FocusEvent]
81
+ blur: [event: FocusEvent]
82
+ clear: []
83
+ keyup: [event: KeyboardEvent]
84
+ keydown: [event: KeyboardEvent]
85
+ }>()
86
+
87
+ const focused = ref(false)
88
+ const nativeEl = ref<HTMLInputElement | HTMLTextAreaElement | null>(null)
89
+
90
+ // radius : prop explicite > composantProps.QInput.radius ; échelle → --q-radius
91
+ const effectiveRadius = useRadius("QInput", () => props.radius)
92
+ const roundedStyle = computed(() => radiusStyle(effectiveRadius.value))
93
+
94
+ const value = computed(() => props.modelValue ?? "")
95
+
96
+ // ═════════ Masque de saisie : # chiffre, A lettre, N alphanumérique, X quelconque ═════════
97
+ const isToken = (c: string): boolean => c === "#" || c === "A" || c === "N" || c === "X"
98
+
99
+ const matchesToken = (tok: string, c: string): boolean =>
100
+ tok === "#"
101
+ ? /[0-9]/.test(c)
102
+ : tok === "A"
103
+ ? /[a-zA-Z]/.test(c)
104
+ : tok === "N"
105
+ ? /[0-9a-zA-Z]/.test(c)
106
+ : true
107
+
108
+ /** Extrait les caractères réels (emplacements tokens) d'une valeur affichée masquée. */
109
+ const unmask = (v: string, fill: boolean): string => {
110
+ if (!props.mask) return v
111
+ let out = ""
112
+ let vi = 0
113
+ for (const ch of props.mask) {
114
+ if (!isToken(ch)) {
115
+ if (v[vi] === ch) vi++ // littéral présent → on le consomme
116
+ continue // sinon : ne pas consommer, c'est un vrai caractère
117
+ }
118
+ if (vi >= v.length) break
119
+ const c = v[vi]!
120
+ vi++
121
+ if (fill && c === "_") continue // emplacement vide
122
+ out += c
123
+ }
124
+ return out
125
+ }
126
+
127
+ /** Applique le masque à une chaîne de caractères bruts (littéraux insérés, caractères invalides sautés). */
128
+ const mask = (raw: string, fill: boolean): string => {
129
+ if (!props.mask) return raw
130
+ let out = ""
131
+ let ri = 0
132
+ for (const ch of props.mask) {
133
+ // Plus de caractères bruts (sans fill) → on s'arrête (pas de littéraux de fin)
134
+ if (ri >= raw.length && !fill) break
135
+ if (!isToken(ch)) {
136
+ out += ch
137
+ continue
138
+ }
139
+ while (ri < raw.length && !matchesToken(ch, raw[ri]!)) ri++
140
+ if (ri >= raw.length) {
141
+ if (fill) out += "_"
142
+ else break
143
+ continue
144
+ }
145
+ out += raw[ri]!
146
+ ri++
147
+ }
148
+ return out
149
+ }
150
+
151
+ /** Valeur affichée dans le champ : applique le masque au modelValue (brut si unmaskedValue). */
152
+ const displayValue = computed(() => {
153
+ const v = String(props.modelValue ?? "")
154
+ if (!props.mask) return v
155
+ const raw = props.unmaskedValue ? v : unmask(v, props.fillMask)
156
+ return mask(raw, props.fillMask)
157
+ })
158
+
159
+ const floatActive = computed(() => focused.value || value.value !== "" || props.stackLabel)
160
+
161
+ const fieldClasses = computed(() =>
162
+ cn(
163
+ "q-input",
164
+ props.outlined && "q-field--outlined",
165
+ props.filled && "q-field--filled",
166
+ props.borderless && "q-field--borderless",
167
+ effectiveRadius.value === true && "q-field--rounded",
168
+ props.dense && "q-field--dense",
169
+ props.error && "q-field--error",
170
+ props.disable && "q-field--disabled",
171
+ floatActive.value && "q-field--float",
172
+ ),
173
+ )
174
+
175
+ const resizeTextarea = async () => {
176
+ const el = nativeEl.value
177
+ if (el && props.autogrow) {
178
+ await nextTick()
179
+ el.style.height = "auto"
180
+ el.style.height = `${el.scrollHeight}px`
181
+ }
182
+ }
183
+
184
+ watch(value, resizeTextarea)
185
+
186
+ const onInput = (e: Event) => {
187
+ const el = e.target as HTMLInputElement | HTMLTextAreaElement
188
+ const raw = el.value
189
+ if (!props.mask) {
190
+ emit("update:modelValue", raw)
191
+ return
192
+ }
193
+ const masked = mask(unmask(raw, props.fillMask), props.fillMask)
194
+ emit("update:modelValue", props.unmaskedValue ? unmask(masked, props.fillMask) : masked)
195
+ // Replique le texte masqué dans le champ et remet le caret à la fin
196
+ el.value = masked
197
+ const len = masked.length
198
+ el.setSelectionRange?.(len, len)
199
+ }
200
+
201
+ const onFocus = (e: FocusEvent) => {
202
+ focused.value = true
203
+ emit("focus", e)
204
+ }
205
+
206
+ const onBlur = (e: FocusEvent) => {
207
+ focused.value = false
208
+ emit("blur", e)
209
+ }
210
+
211
+ const onClear = () => {
212
+ emit("update:modelValue", "")
213
+ emit("clear")
214
+ nativeEl.value?.focus()
215
+ }
216
+
217
+ const showClear = computed(
218
+ () => props.clearable && !props.disable && !props.readonly && value.value !== "",
219
+ )
220
+
221
+ const inputAttrs = computed(() => ({
222
+ type: props.type,
223
+ maxlength: props.maxlength,
224
+ placeholder: props.placeholder,
225
+ disabled: props.disable || undefined,
226
+ readonly: props.readonly || undefined,
227
+ }))
228
+ </script>
229
+
230
+ <template>
231
+ <div class="q-input" :class="fieldClasses" :style="roundedStyle">
232
+ <label v-if="label" class="q-field__label-stack">{{ label }}</label>
233
+ <div class="q-field__control">
234
+ <slot name="prepend">
235
+ <Icon v-if="iconLeft" :icon="iconLeft" class="q-field__icon" aria-hidden="true" />
236
+ </slot>
237
+ <span v-if="prefix" class="q-field__prefix">{{ prefix }}</span>
238
+ <textarea
239
+ v-if="autogrow"
240
+ ref="nativeEl"
241
+ class="q-field__native"
242
+ :value="displayValue"
243
+ rows="1"
244
+ v-bind="inputAttrs"
245
+ @input="onInput"
246
+ @focus="onFocus"
247
+ @blur="onBlur"
248
+ @keyup="(e) => emit('keyup', e)"
249
+ @keydown="(e) => emit('keydown', e)"
250
+ />
251
+ <input
252
+ v-else
253
+ ref="nativeEl"
254
+ class="q-field__native"
255
+ :value="displayValue"
256
+ v-bind="inputAttrs"
257
+ @input="onInput"
258
+ @focus="onFocus"
259
+ @blur="onBlur"
260
+ @keyup="(e) => emit('keyup', e)"
261
+ @keydown="(e) => emit('keydown', e)"
262
+ />
263
+ <span v-if="suffix" class="q-field__suffix">{{ suffix }}</span>
264
+ <button
265
+ v-if="showClear"
266
+ class="q-field__clear"
267
+ type="button"
268
+ aria-label="Effacer"
269
+ @click="onClear"
270
+ >
271
+ <Icon :icon="icons.x" aria-hidden="true" />
272
+ </button>
273
+ <slot name="append">
274
+ <Icon v-if="iconRight" :icon="iconRight" class="q-field__icon" aria-hidden="true" />
275
+ </slot>
276
+ </div>
277
+ <div class="q-field__bottom">
278
+ <div v-if="error" class="q-field__error">
279
+ <slot name="error">{{ errorMessage }}</slot>
280
+ </div>
281
+ <div v-else-if="hint || $slots.hint" class="q-field__hint">
282
+ <slot name="hint">{{ hint }}</slot>
283
+ </div>
284
+ <div v-if="counter && maxlength" class="q-field__counter">
285
+ {{ String(value).length }}/{{ maxlength }}
286
+ </div>
287
+ </div>
288
+ </div>
289
+ </template>