@leavepulse/ui 0.44.0 → 0.45.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.
- package/README.md +49 -3
- package/dist/assets/{LpCommandPalette-DCP5g2ik.css → LpCommandPalette-HzAA4gri.css} +21 -21
- package/dist/assets/{LpRollingText-CR1mb0Vt.css → LpRollingText-BGnE6q9j.css} +3 -0
- package/dist/assets/{LpShortcutsDialog-ChUyU7oV.css → LpShortcutsDialog-CqYX3yy6.css} +28 -7
- package/dist/assets/{LpTable-DDQNdf86.css → LpTable-CPwWxTJS.css} +49 -9
- package/dist/chunks/{ContextMenuItems-CrfeNPWT.js → ContextMenuItems-BtKU7074.js} +2 -1
- package/dist/chunks/{LayoutCanvas-D-jPOAvg.js → LayoutCanvas-BkaGXTjx.js} +1 -1
- package/dist/chunks/{LayoutNode-D8Xy9N-o.js → LayoutNode-B3_9E1Xa.js} +9 -5
- package/dist/chunks/{LpActionBar-BbwCF6l5.js → LpActionBar-CnfFVIwC.js} +3 -3
- package/dist/chunks/{LpAddressInput-BrOqkZqg.js → LpAddressInput-CJOYGZlI.js} +7 -7
- package/dist/chunks/{LpAlert-DkOIjsbq.js → LpAlert-B6ilrM7T.js} +1 -1
- package/dist/chunks/{LpAppShell-BUKPcHco.js → LpAppShell-B07Lf48e.js} +4 -4
- package/dist/chunks/{LpAutocomplete-DxV3Y9h4.js → LpAutocomplete-C8IgSMsi.js} +2 -1
- package/dist/chunks/{LpAvatar-ClBbEYZN.js → LpAvatar-C1XStdOo.js} +3 -3
- package/dist/chunks/{LpBreadcrumbs-B8IJgFIc.js → LpBreadcrumbs-CQJd73Gm.js} +1 -1
- package/dist/chunks/{LpButton-kqJBHIIb.js → LpButton-CKSwC6Mu.js} +1 -1
- package/dist/chunks/{LpCalendar-B7Ko5RdR.js → LpCalendar-DrA2McTU.js} +1 -1
- package/dist/chunks/{LpCard-CpRByR0q.js → LpCard-C2IvucmX.js} +1 -1
- package/dist/chunks/{LpCheckbox-DpqchzH4.js → LpCheckbox-Bkj13CbW.js} +1 -1
- package/dist/chunks/{LpCodeBlock-DeHJPq7p.js → LpCodeBlock-ca7ANFyW.js} +1 -1
- package/dist/chunks/{LpColorPicker-Bhf1qxhB.js → LpColorPicker-zz4VGkG4.js} +46 -20
- package/dist/chunks/{LpConfirmDialog-CIUOSnWo.js → LpConfirmDialog-BTVAR4tF.js} +3 -3
- package/dist/chunks/{LpConfirmHost-CDm0Wtpv.js → LpConfirmHost-Ci_uCHgQ.js} +1 -1
- package/dist/chunks/{LpContextMenu-DQw5dGfG.js → LpContextMenu-BqkAYa9X.js} +2 -1
- package/dist/chunks/{LpDatePicker-Drirx1AK.js → LpDatePicker-BrdFtSTy.js} +16 -10
- package/dist/chunks/LpDateRangePicker-Drd7EbIz.js +210 -0
- package/dist/chunks/{LpDisclosure-ChQYmjlR.js → LpDisclosure-Cc23-JOT.js} +1 -1
- package/dist/chunks/{LpDrawer-bScx-KMO.js → LpDrawer-CfRhHwmE.js} +3 -3
- package/dist/chunks/{LpDropdownMenu-DJpoqRCb.js → LpDropdownMenu-DdJGQzPn.js} +2 -1
- package/dist/chunks/{LpEmptyState-Dsdqz1Wj.js → LpEmptyState-BJbZXC8D.js} +1 -1
- package/dist/chunks/LpFieldNotice-wtieLUTj.js +64 -0
- package/dist/chunks/{LpFileTree-CSI_5SK2.js → LpFileTree-CNurOvkV.js} +3 -3
- package/dist/chunks/{LpFileTreeNode-B4MiSQX2.js → LpFileTreeNode-D_qRRdsH.js} +3 -3
- package/dist/chunks/{LpFormField-CIk5qosh.js → LpFormField-CtVYj6tO.js} +29 -15
- package/dist/chunks/{LpIcon-CCnX5_2j.js → LpIcon-DYtaid0K.js} +20 -6
- package/dist/chunks/{LpInput-C0e8y8_d.js → LpInput-D-YGQbEv.js} +39 -16
- package/dist/chunks/{LpLightbox-Mr0W_6HK.js → LpLightbox-DSDahF23.js} +1 -1
- package/dist/chunks/{LpLogViewer-YBOaFnc_.js → LpLogViewer-KypbgOyo.js} +252 -58
- package/dist/chunks/{LpMapPicker-NUzS9e37.js → LpMapPicker-Cc84LaM3.js} +1 -1
- package/dist/chunks/{LpMediaGallery-DfJmMW0I.js → LpMediaGallery-YKr-cQPN.js} +2 -2
- package/dist/chunks/{LpModal-DPwGKBPv.js → LpModal-BzTVeNqe.js} +3 -3
- package/dist/chunks/{LpModalNav-DPg5H9f3.js → LpModalNav-CjABSGCu.js} +14 -8
- package/dist/chunks/{LpNotificationBell-t5yn-siX.js → LpNotificationBell-CCwyJP5C.js} +26 -14
- package/dist/chunks/{LpNumberField-DWnQKktS.js → LpNumberField-B77O-z28.js} +1 -1
- package/dist/chunks/{LpPagination-DFngX7Xo.js → LpPagination-BcDlo3r_.js} +1 -1
- package/dist/chunks/{LpPasswordInput-CWVDuBJ5.js → LpPasswordInput-eKzQhbQP.js} +2 -2
- package/dist/chunks/{LpPhoneInput-CD-ZTeSd.js → LpPhoneInput-BCbrrhgE.js} +126 -23
- package/dist/chunks/{LpPie-BLco31Nx.js → LpPie-BEJ2SfI6.js} +9 -10
- package/dist/chunks/{LpPopover-p_e_shN3.js → LpPopover-DCKeqiVn.js} +1 -0
- package/dist/chunks/LpRangeCalendar-DZuaRV0W.js +331 -0
- package/dist/chunks/{LpRollingText-CM5gkSeF.js → LpRollingText-DB7upf-5.js} +1 -1
- package/dist/chunks/{LpScrollArea-D8xyKXcF.js → LpScrollArea-Bry1fdOM.js} +14 -2
- package/dist/chunks/{LpSection-aM6J_u51.js → LpSection-tKEH0jYf.js} +1 -1
- package/dist/chunks/{LpSegmented-Dejd7cIx.js → LpSegmented-C09EEam2.js} +1 -1
- package/dist/chunks/{LpSelect-DrRD9I7L.js → LpSelect-DNIL6MB9.js} +20 -23
- package/dist/chunks/{LpSidebar-BgwUNWpJ.js → LpSidebar-Bwws_X29.js} +3 -3
- package/dist/chunks/{LpSidebarNav-Bqxz8aWT.js → LpSidebarNav-kx-LdqNU.js} +3 -3
- package/dist/chunks/{LpStat-dXtyrdX9.js → LpStat-B-7udBRP.js} +1 -1
- package/dist/chunks/{LpStepper-Bk-XaQYU.js → LpStepper-C7XiVKZj.js} +34 -19
- package/dist/chunks/{LpSwitch-B1aLKulj.js → LpSwitch-OrcOJX9m.js} +1 -1
- package/dist/chunks/LpTableFilter-BV8PpLM9.js +158 -0
- package/dist/chunks/{LpTabs-_qMVaZJ6.js → LpTabs-29iW1XOx.js} +1 -1
- package/dist/chunks/LpTagsInput-D1SuqbhB.js +300 -0
- package/dist/chunks/{LpTextarea-yP8hGedR.js → LpTextarea-uuScWo6-.js} +1 -1
- package/dist/chunks/{LpThemeProvider-Wjm7Tuit.js → LpThemeProvider-Be4r2TQX.js} +1 -1
- package/dist/chunks/{LpThemeSwitcher-CKLc10KZ.js → LpThemeSwitcher-B4aVPbNC.js} +19 -22
- package/dist/chunks/{LpTimeField-C5HRgS5H.js → LpTimeField-Cqe7fXnh.js} +1 -1
- package/dist/chunks/{LpToaster--oT6lfhL.js → LpToaster-DTtWuKvq.js} +76 -9
- package/dist/chunks/{LpTooltip-CBdzeKNf.js → LpTooltip-CXVKcOXT.js} +3 -2
- package/dist/chunks/{LpTopologyCanvas-DocsLal3.js → LpTopologyCanvas-6ozNrQKh.js} +2 -1
- package/dist/chunks/{LpUptimeBar-DLtxOMRy.js → LpUptimeBar-ugcv25xj.js} +2 -2
- package/dist/chunks/icons-CkA0njzm.js +43 -0
- package/dist/chunks/{useTheme-C8JHUlPM.js → useTheme-DJm1JSsY.js} +47 -50
- package/dist/component-names.d.ts +1 -1
- package/dist/components/ContextMenuItems.vue.js +1 -1
- package/dist/components/LayoutCanvas.vue.js +1 -1
- package/dist/components/LayoutNode.vue.js +1 -1
- package/dist/components/LpActionBar.vue.js +1 -1
- package/dist/components/LpAddressInput.vue.js +1 -1
- package/dist/components/LpAlert.vue.js +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpAutocomplete.vue.js +1 -1
- package/dist/components/LpAvatar.vue.js +1 -1
- package/dist/components/LpBreadcrumbs.vue.js +1 -1
- package/dist/components/LpButton.vue.d.ts +2 -2
- package/dist/components/LpButton.vue.js +1 -1
- package/dist/components/LpCalendar.vue.js +1 -1
- package/dist/components/LpCard.vue.js +1 -1
- package/dist/components/LpChart.vue.d.ts +16 -1
- package/dist/components/LpChart.vue.js +63 -5
- package/dist/components/LpCheckbox.vue.js +1 -1
- package/dist/components/LpCodeBlock.vue.js +1 -1
- package/dist/components/LpColorPicker.vue.d.ts +2 -2
- package/dist/components/LpColorPicker.vue.js +1 -1
- package/dist/components/LpCommandPalette.vue.d.ts +6 -0
- package/dist/components/LpCommandPalette.vue.js +62 -11
- package/dist/components/LpConfirmDialog.vue.js +1 -1
- package/dist/components/LpConfirmHost.vue.js +1 -1
- package/dist/components/LpContextMenu.vue.js +1 -1
- package/dist/components/LpDatePicker.vue.js +1 -1
- package/dist/components/LpDateRangePicker.vue.d.ts +60 -0
- package/dist/components/LpDateRangePicker.vue.js +4 -0
- package/dist/components/LpDisclosure.vue.js +1 -1
- package/dist/components/LpDrawer.vue.js +1 -1
- package/dist/components/LpDropdownMenu.vue.js +1 -1
- package/dist/components/LpDropzone.vue.js +1 -1
- package/dist/components/LpEmptyState.vue.js +1 -1
- package/dist/components/LpFieldNotice.vue.d.ts +8 -0
- package/dist/components/LpFieldNotice.vue.js +4 -0
- package/dist/components/LpFileTree.vue.js +1 -1
- package/dist/components/LpFileTreeNode.vue.js +1 -1
- package/dist/components/LpFormField.vue.d.ts +4 -1
- package/dist/components/LpFormField.vue.js +1 -1
- package/dist/components/LpIcon.vue.d.ts +0 -1
- package/dist/components/LpIcon.vue.js +1 -1
- package/dist/components/LpImageEditor.vue.js +2 -2
- package/dist/components/LpInput.vue.d.ts +19 -0
- package/dist/components/LpInput.vue.js +1 -1
- package/dist/components/LpLightbox.vue.js +1 -1
- package/dist/components/LpLogViewer.vue.d.ts +52 -1
- package/dist/components/LpLogViewer.vue.js +1 -1
- package/dist/components/LpMapPicker.vue.js +1 -1
- package/dist/components/LpMediaGallery.vue.js +1 -1
- package/dist/components/LpModal.vue.js +1 -1
- package/dist/components/LpModalNav.vue.js +1 -1
- package/dist/components/LpNotificationBell.vue.js +1 -1
- package/dist/components/LpNumberField.vue.js +1 -1
- package/dist/components/LpPagination.vue.js +1 -1
- package/dist/components/LpPasswordInput.vue.js +1 -1
- package/dist/components/LpPhoneInput.vue.d.ts +13 -0
- package/dist/components/LpPhoneInput.vue.js +1 -1
- package/dist/components/LpPie.vue.js +1 -1
- package/dist/components/LpPopover.vue.js +1 -1
- package/dist/components/LpProgress.vue.d.ts +1 -1
- package/dist/components/LpRangeCalendar.vue.d.ts +51 -0
- package/dist/components/LpRangeCalendar.vue.js +4 -0
- package/dist/components/LpRollingText.vue.d.ts +1 -1
- package/dist/components/LpRollingText.vue.js +1 -1
- package/dist/components/LpScrollArea.vue.d.ts +2 -2
- package/dist/components/LpScrollArea.vue.js +1 -1
- package/dist/components/LpSection.vue.js +1 -1
- package/dist/components/LpSegmented.vue.js +1 -1
- package/dist/components/LpSelect.vue.js +1 -1
- package/dist/components/LpShortcutsDialog.vue.d.ts +6 -0
- package/dist/components/LpShortcutsDialog.vue.js +56 -14
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpSidebarNav.vue.js +1 -1
- package/dist/components/LpStat.vue.js +1 -1
- package/dist/components/LpStepper.vue.js +1 -1
- package/dist/components/LpSwitch.vue.js +1 -1
- package/dist/components/LpTable.vue.d.ts +84 -2
- package/dist/components/LpTable.vue.js +481 -107
- package/dist/components/LpTableFilter.vue.d.ts +31 -0
- package/dist/components/LpTableFilter.vue.js +4 -0
- package/dist/components/LpTabs.vue.js +1 -1
- package/dist/components/LpTagsInput.vue.d.ts +77 -0
- package/dist/components/LpTagsInput.vue.js +4 -0
- package/dist/components/LpTextarea.vue.js +1 -1
- package/dist/components/LpThemeProvider.vue.js +1 -1
- package/dist/components/LpThemeSwitcher.vue.d.ts +2 -2
- package/dist/components/LpThemeSwitcher.vue.js +1 -1
- package/dist/components/LpTimeField.vue.js +1 -1
- package/dist/components/LpToaster.vue.d.ts +2 -0
- package/dist/components/LpToaster.vue.js +1 -1
- package/dist/components/LpTooltip.vue.js +1 -1
- package/dist/components/LpTopologyCanvas.vue.js +1 -1
- package/dist/components/LpUptimeBar.vue.js +1 -1
- package/dist/components/chart.js +3 -2
- package/dist/components/commandSearch.js +3 -0
- package/dist/components/dateRange.d.ts +43 -0
- package/dist/components/dateRange.js +76 -0
- package/dist/components/exportData.d.ts +58 -0
- package/dist/components/exportData.js +165 -0
- package/dist/components/logSearch.d.ts +18 -0
- package/dist/components/logSearch.js +36 -0
- package/dist/components/phone.d.ts +41 -0
- package/dist/components/phone.js +80 -0
- package/dist/components/tableFilter.d.ts +36 -0
- package/dist/components/tableFilter.js +84 -0
- package/dist/components/tags.d.ts +20 -0
- package/dist/components/tags.js +37 -0
- package/dist/composables/useField.d.ts +4 -0
- package/dist/composables/useFieldNotice.d.ts +17 -0
- package/dist/composables/useFieldNotice.js +28 -0
- package/dist/composables/useForm.d.ts +53 -0
- package/dist/composables/useForm.js +207 -0
- package/dist/composables/useHotkeys.d.ts +65 -5
- package/dist/composables/useHotkeys.js +170 -9
- package/dist/composables/useInputMask.d.ts +59 -0
- package/dist/composables/useInputMask.js +170 -0
- package/dist/composables/useLocale.d.ts +2 -0
- package/dist/composables/useLocale.js +9 -0
- package/dist/composables/usePaintSelect.d.ts +22 -0
- package/dist/composables/usePaintSelect.js +169 -0
- package/dist/composables/useShift.js +7 -2
- package/dist/icons-all.d.ts +1 -0
- package/dist/icons-all.js +3 -0
- package/dist/icons.d.ts +14 -1
- package/dist/index.d.ts +29 -3
- package/dist/index.js +195 -138
- package/dist/kit-icons.d.ts +2 -0
- package/dist/theme/backdrop.d.ts +12 -0
- package/dist/theme/presets.d.ts +0 -2
- package/package.json +13 -4
- package/src/component-names.ts +4 -0
- package/src/components/ContextMenuItems.vue +1 -1
- package/src/components/LayoutNode.vue +11 -2
- package/src/components/LpActionBar.vue +1 -1
- package/src/components/LpAutocomplete.vue +1 -0
- package/src/components/LpAvatar.vue +2 -2
- package/src/components/LpButton.vue +1 -1
- package/src/components/LpChart.vue +80 -1
- package/src/components/LpColorPicker.vue +34 -1
- package/src/components/LpCommandPalette.vue +82 -8
- package/src/components/LpContextMenu.vue +1 -1
- package/src/components/LpDatePicker.vue +11 -4
- package/src/components/LpDateRangePicker.vue +226 -0
- package/src/components/LpDrawer.vue +1 -1
- package/src/components/LpDropdownMenu.vue +1 -0
- package/src/components/LpFieldNotice.vue +65 -0
- package/src/components/LpFormField.vue +33 -4
- package/src/components/LpIcon.vue +22 -4
- package/src/components/LpInput.vue +46 -6
- package/src/components/LpLogViewer.vue +225 -22
- package/src/components/LpModal.vue +2 -2
- package/src/components/LpModalNav.vue +13 -2
- package/src/components/LpNotificationBell.vue +15 -7
- package/src/components/LpPhoneInput.vue +179 -20
- package/src/components/LpPie.vue +8 -5
- package/src/components/LpPopover.vue +1 -0
- package/src/components/LpRangeCalendar.vue +365 -0
- package/src/components/LpRollingText.vue +3 -0
- package/src/components/LpScrollArea.vue +22 -2
- package/src/components/LpSelect.vue +0 -0
- package/src/components/LpShortcutsDialog.vue +77 -8
- package/src/components/LpStepper.vue +23 -10
- package/src/components/LpSwitch.vue +1 -1
- package/src/components/LpTable.vue +584 -65
- package/src/components/LpTableFilter.vue +175 -0
- package/src/components/LpTagsInput.vue +364 -0
- package/src/components/LpThemeSwitcher.vue +19 -23
- package/src/components/LpToaster.vue +91 -5
- package/src/components/LpTooltip.vue +9 -2
- package/src/components/LpTopologyCanvas.vue +7 -1
- package/src/components/chart.ts +5 -3
- package/src/components/commandSearch.ts +6 -0
- package/src/components/dateRange.ts +134 -0
- package/src/components/exportData.ts +228 -0
- package/src/components/logSearch.ts +46 -0
- package/src/components/phone.ts +140 -0
- package/src/components/tableFilter.ts +122 -0
- package/src/components/tags.ts +62 -0
- package/src/composables/useField.ts +4 -0
- package/src/composables/useFieldNotice.ts +59 -0
- package/src/composables/useForm.ts +314 -0
- package/src/composables/useHotkeys.ts +269 -13
- package/src/composables/useInputMask.ts +254 -0
- package/src/composables/useLocale.ts +14 -0
- package/src/composables/usePaintSelect.ts +243 -0
- package/src/composables/useShift.ts +13 -2
- package/src/iconSubset.d.mts +10 -0
- package/src/iconSubset.mjs +51 -0
- package/src/icons-all.ts +10 -0
- package/src/icons.ts +59 -8
- package/src/index.ts +37 -3
- package/src/kit-icons.ts +5 -0
- package/src/nuxt.ts +30 -2
- package/src/runtime/theme.plugin.ts +2 -2
- package/src/theme/backdrop.ts +69 -0
- package/src/theme/presets.ts +6 -64
- package/src/tokens/tokens.css +53 -4
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
export interface TableFilterOption {
|
|
3
|
+
value: unknown
|
|
4
|
+
label: string
|
|
5
|
+
/** Rows holding the value; omitted when unknown (a server-backed table). */
|
|
6
|
+
count?: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface TableFilterLabels {
|
|
10
|
+
search: string
|
|
11
|
+
all: string
|
|
12
|
+
none: string
|
|
13
|
+
invert: string
|
|
14
|
+
only: string
|
|
15
|
+
onlyShown: string
|
|
16
|
+
addShown: string
|
|
17
|
+
noValues: string
|
|
18
|
+
}
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<script setup lang="ts">
|
|
22
|
+
/*
|
|
23
|
+
* The value filter of one LpTable column, opened from the funnel in its
|
|
24
|
+
* header. Built for columns with many values: a search narrows the LIST
|
|
25
|
+
* without touching what is ticked, "Only" next to a value shows just that one
|
|
26
|
+
* in a click, and after a search the matches can be taken as a whole.
|
|
27
|
+
*/
|
|
28
|
+
import { computed, ref } from "vue"
|
|
29
|
+
import LpCheckbox from "./LpCheckbox.vue"
|
|
30
|
+
import LpInput from "./LpInput.vue"
|
|
31
|
+
import LpShift from "./LpShift.vue"
|
|
32
|
+
import { usePaintSelect } from "../composables/usePaintSelect"
|
|
33
|
+
import { hideAllValues, invertHidden, matchesQuery, onlyValues, unhideValues } from "./tableFilter"
|
|
34
|
+
|
|
35
|
+
const props = defineProps<{
|
|
36
|
+
options: TableFilterOption[]
|
|
37
|
+
hidden: ReadonlySet<unknown>
|
|
38
|
+
labels: TableFilterLabels
|
|
39
|
+
}>()
|
|
40
|
+
|
|
41
|
+
const emit = defineEmits<{ (e: "update:hidden", value: Set<unknown>): void }>()
|
|
42
|
+
|
|
43
|
+
/** Past this many values a list is easier searched than scanned. */
|
|
44
|
+
const SEARCH_FROM = 8
|
|
45
|
+
|
|
46
|
+
const query = ref("")
|
|
47
|
+
const values = computed(() => props.options.map((o) => o.value))
|
|
48
|
+
const shown = computed(() => props.options.filter((o) => matchesQuery(o.label, query.value)))
|
|
49
|
+
|
|
50
|
+
function set(next: Set<unknown>) {
|
|
51
|
+
emit("update:hidden", next)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toggle(value: unknown) {
|
|
55
|
+
const next = new Set(props.hidden)
|
|
56
|
+
if (next.has(value)) next.delete(value)
|
|
57
|
+
else next.add(value)
|
|
58
|
+
set(next)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const list = ref<HTMLElement | null>(null)
|
|
62
|
+
|
|
63
|
+
/*
|
|
64
|
+
* Press a value's tick and sweep down the list to tick or clear the run, the
|
|
65
|
+
* same way rows of the table are painted. The selection here is the values
|
|
66
|
+
* shown, so painting writes back the complement as the hidden set.
|
|
67
|
+
*/
|
|
68
|
+
const byKey = computed(() => new Map(props.options.map((o) => [String(o.value), o.value])))
|
|
69
|
+
const paint = usePaintSelect<unknown>({
|
|
70
|
+
root: list,
|
|
71
|
+
selection: () => values.value.filter((v) => !props.hidden.has(v)),
|
|
72
|
+
set: (shownValues) => {
|
|
73
|
+
const keep = new Set(shownValues)
|
|
74
|
+
set(new Set(values.value.filter((v) => !keep.has(v))))
|
|
75
|
+
},
|
|
76
|
+
resolve: (raw) => (byKey.value.has(raw) ? byKey.value.get(raw) : undefined),
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
/* Up and down walk the ticks, so a long list is not a long run of Tab. */
|
|
80
|
+
function onListKeydown(event: KeyboardEvent) {
|
|
81
|
+
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
|
|
82
|
+
const boxes = [...(list.value?.querySelectorAll<HTMLElement>("[role=checkbox]") ?? [])]
|
|
83
|
+
const at = boxes.indexOf(document.activeElement as HTMLElement)
|
|
84
|
+
const next = boxes[at + (event.key === "ArrowDown" ? 1 : -1)]
|
|
85
|
+
if (!next) return
|
|
86
|
+
event.preventDefault()
|
|
87
|
+
next.focus()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const action =
|
|
91
|
+
"rounded-control px-1.5 py-0.5 text-xs font-medium text-muted outline-none transition-colors duration-fast hover:bg-surface-soft hover:text-ink focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
|
|
92
|
+
</script>
|
|
93
|
+
|
|
94
|
+
<template>
|
|
95
|
+
<div class="flex flex-col gap-2">
|
|
96
|
+
<LpInput
|
|
97
|
+
v-if="options.length >= SEARCH_FROM"
|
|
98
|
+
v-model="query"
|
|
99
|
+
size="sm"
|
|
100
|
+
icon="lucide:search"
|
|
101
|
+
:placeholder="labels.search"
|
|
102
|
+
:aria-label="labels.search"
|
|
103
|
+
/>
|
|
104
|
+
|
|
105
|
+
<div class="flex flex-wrap items-center gap-1">
|
|
106
|
+
<template v-if="query">
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
:class="action"
|
|
110
|
+
:disabled="!shown.length"
|
|
111
|
+
@click="set(onlyValues(values, shown.map((o) => o.value)))"
|
|
112
|
+
>
|
|
113
|
+
{{ labels.onlyShown.replace("{count}", String(shown.length)) }}
|
|
114
|
+
</button>
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
:class="action"
|
|
118
|
+
:disabled="!shown.length"
|
|
119
|
+
@click="set(unhideValues(hidden, shown.map((o) => o.value)))"
|
|
120
|
+
>
|
|
121
|
+
{{ labels.addShown.replace("{count}", String(shown.length)) }}
|
|
122
|
+
</button>
|
|
123
|
+
</template>
|
|
124
|
+
<template v-else>
|
|
125
|
+
<button type="button" :class="action" :disabled="!hidden.size" @click="set(new Set())">
|
|
126
|
+
{{ labels.all }}
|
|
127
|
+
</button>
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
:class="action"
|
|
131
|
+
:disabled="hidden.size === options.length"
|
|
132
|
+
@click="set(hideAllValues(values))"
|
|
133
|
+
>
|
|
134
|
+
{{ labels.none }}
|
|
135
|
+
</button>
|
|
136
|
+
<button type="button" :class="action" @click="set(invertHidden(values, hidden))">
|
|
137
|
+
{{ labels.invert }}
|
|
138
|
+
</button>
|
|
139
|
+
</template>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<LpShift>
|
|
143
|
+
<div
|
|
144
|
+
ref="list"
|
|
145
|
+
class="-mx-1 flex max-h-64 flex-col overflow-y-auto overscroll-contain"
|
|
146
|
+
@keydown="onListKeydown"
|
|
147
|
+
>
|
|
148
|
+
<div
|
|
149
|
+
v-for="option in shown"
|
|
150
|
+
:key="String(option.value)"
|
|
151
|
+
class="group/opt flex items-center gap-2 rounded-control px-1.5 py-1 transition-colors duration-fast select-none hover:bg-surface-soft"
|
|
152
|
+
:data-paint-key="String(option.value)"
|
|
153
|
+
>
|
|
154
|
+
<LpCheckbox
|
|
155
|
+
:model-value="!hidden.has(option.value)"
|
|
156
|
+
class="min-w-0 flex-1"
|
|
157
|
+
@pointerdown="paint.onPointerDown($event, option.value)"
|
|
158
|
+
@update:model-value="toggle(option.value)"
|
|
159
|
+
>
|
|
160
|
+
<span class="truncate">{{ option.label }}</span>
|
|
161
|
+
</LpCheckbox>
|
|
162
|
+
<span v-if="option.count !== undefined" class="text-xs text-muted tabular-nums">{{ option.count }}</span>
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
class="rounded-control px-1.5 py-0.5 text-xs font-medium text-brand opacity-0 outline-none transition-opacity duration-fast group-hover/opt:opacity-100 hover:bg-brand-soft focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring"
|
|
166
|
+
@click="set(onlyValues(values, [option.value]))"
|
|
167
|
+
>
|
|
168
|
+
{{ labels.only }}
|
|
169
|
+
</button>
|
|
170
|
+
</div>
|
|
171
|
+
<p v-if="!shown.length" class="px-1.5 py-2 text-xs text-muted">{{ labels.noValues }}</p>
|
|
172
|
+
</div>
|
|
173
|
+
</LpShift>
|
|
174
|
+
</div>
|
|
175
|
+
</template>
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
export default { inheritAttrs: false }
|
|
3
|
+
|
|
4
|
+
export type TagsInputLabels = {
|
|
5
|
+
remove: string
|
|
6
|
+
duplicate: string
|
|
7
|
+
max: string
|
|
8
|
+
added: string
|
|
9
|
+
removed: string
|
|
10
|
+
}
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<script setup lang="ts">
|
|
14
|
+
/*
|
|
15
|
+
* Free-form list of short values: emails, labels, hostnames. Typing a
|
|
16
|
+
* delimiter or Enter turns the text into a tag, a paste is split into many,
|
|
17
|
+
* Backspace in an empty field selects the last tag and removes it on the next
|
|
18
|
+
* press, and the arrow keys walk the tags (reka TagsInput).
|
|
19
|
+
*
|
|
20
|
+
* Adding is the kit's own (see tags.ts) rather than reka's, so a tag that
|
|
21
|
+
* fails `validate`, the limit or the duplicate policy stays in the field for
|
|
22
|
+
* fixing instead of vanishing. The reason floats under the field
|
|
23
|
+
* (LpFieldNotice) rather than taking a line, so a refused paste never moves
|
|
24
|
+
* the form, and a repeat makes the tag it repeats pulse.
|
|
25
|
+
*/
|
|
26
|
+
import {
|
|
27
|
+
TagsInputInput,
|
|
28
|
+
TagsInputItem,
|
|
29
|
+
TagsInputItemDelete,
|
|
30
|
+
TagsInputItemText,
|
|
31
|
+
TagsInputRoot,
|
|
32
|
+
} from "reka-ui"
|
|
33
|
+
import { computed, ref, useAttrs, useId } from "vue"
|
|
34
|
+
import { fieldMessageId, useField, useFieldControl } from "../composables/useField"
|
|
35
|
+
import { prefersReducedMotion } from "../composables/easing"
|
|
36
|
+
import { useFieldNotice, type FieldNoticeOptions } from "../composables/useFieldNotice"
|
|
37
|
+
import { useShift } from "../composables/useShift"
|
|
38
|
+
import { useLabels } from "../composables/useLpConfig"
|
|
39
|
+
import { mergeClass } from "../composables/useMergedClass"
|
|
40
|
+
import LpFieldNotice from "./LpFieldNotice.vue"
|
|
41
|
+
import LpFormField from "./LpFormField.vue"
|
|
42
|
+
import LpIcon from "./LpIcon.vue"
|
|
43
|
+
import { addTags, splitTags, type TagDuplicates, type TagRejection, type TagValidator } from "./tags"
|
|
44
|
+
|
|
45
|
+
const props = withDefaults(
|
|
46
|
+
defineProps<{
|
|
47
|
+
modelValue?: string[]
|
|
48
|
+
placeholder?: string
|
|
49
|
+
/** Characters (or a RegExp) that end a tag while typing and split a paste. */
|
|
50
|
+
delimiter?: string | readonly string[] | RegExp
|
|
51
|
+
/** At most this many tags. */
|
|
52
|
+
max?: number
|
|
53
|
+
/**
|
|
54
|
+
* "reject" keeps a repeat typed with a note, "ignore" drops it with a passing
|
|
55
|
+
* note, "allow" keeps it. Either way the tag it repeats pulses.
|
|
56
|
+
*/
|
|
57
|
+
duplicates?: TagDuplicates
|
|
58
|
+
/** Return an error message to refuse a tag. */
|
|
59
|
+
validate?: TagValidator
|
|
60
|
+
/** Applied to each tag before it is checked and stored (trim is always applied). */
|
|
61
|
+
normalize?: (tag: string) => string
|
|
62
|
+
/** Turn leftover text into a tag when the field loses focus. */
|
|
63
|
+
addOnBlur?: boolean
|
|
64
|
+
disabled?: boolean
|
|
65
|
+
invalid?: boolean
|
|
66
|
+
required?: boolean
|
|
67
|
+
label?: string
|
|
68
|
+
hint?: string
|
|
69
|
+
error?: string
|
|
70
|
+
labels?: Partial<TagsInputLabels>
|
|
71
|
+
}>(),
|
|
72
|
+
{ modelValue: () => [], delimiter: () => [",", "\n", "\t"], duplicates: "reject", addOnBlur: true },
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const emit = defineEmits<{
|
|
76
|
+
(e: "update:modelValue", value: string[]): void
|
|
77
|
+
(e: "reject", rejected: TagRejection[]): void
|
|
78
|
+
(e: "blur", event: FocusEvent): void
|
|
79
|
+
}>()
|
|
80
|
+
|
|
81
|
+
const text = useLabels<TagsInputLabels>(
|
|
82
|
+
"tagsInput",
|
|
83
|
+
{
|
|
84
|
+
remove: "Remove {tag}",
|
|
85
|
+
duplicate: "{tag} is already added",
|
|
86
|
+
max: "Up to {max} items",
|
|
87
|
+
added: "{tag} added",
|
|
88
|
+
removed: "{tag} removed",
|
|
89
|
+
},
|
|
90
|
+
() => props.labels,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
const attrs = useAttrs()
|
|
94
|
+
const hasField = computed(() => !!props.label || !!props.hint || !!props.error)
|
|
95
|
+
/*
|
|
96
|
+
* Notes go to the field that frames the control, which fades its own hint out
|
|
97
|
+
* under them: this component's own LpFormField when it has a label or hint,
|
|
98
|
+
* the one around it otherwise, and a notice of its own only when it stands
|
|
99
|
+
* alone.
|
|
100
|
+
*/
|
|
101
|
+
const notice = useFieldNotice()
|
|
102
|
+
const outerField = useField()
|
|
103
|
+
type FieldHandle = { notify: (text: string, options?: FieldNoticeOptions) => void; clearNotice: () => void }
|
|
104
|
+
const ownField = ref<FieldHandle | null>(null)
|
|
105
|
+
function bindField(c: unknown): void {
|
|
106
|
+
ownField.value = hasField.value && c ? (c as FieldHandle) : null
|
|
107
|
+
}
|
|
108
|
+
const standalone = computed(() => !hasField.value && !outerField?.value.notify)
|
|
109
|
+
function say(message: string, options?: FieldNoticeOptions): void {
|
|
110
|
+
if (hasField.value) ownField.value?.notify(message, options)
|
|
111
|
+
else if (outerField?.value.notify) outerField.value.notify(message, options)
|
|
112
|
+
else notice.show(message, options)
|
|
113
|
+
}
|
|
114
|
+
function unsay(): void {
|
|
115
|
+
if (hasField.value) ownField.value?.clearNotice()
|
|
116
|
+
else if (outerField?.value.clearNotice) outerField.value.clearNotice()
|
|
117
|
+
else notice.clear()
|
|
118
|
+
}
|
|
119
|
+
/** A refusal the user still has to act on: the text stays typed and the field reads invalid. */
|
|
120
|
+
const stuck = ref(false)
|
|
121
|
+
const announcement = ref("")
|
|
122
|
+
const noticeId = `${useId()}-notice`
|
|
123
|
+
|
|
124
|
+
const field = useFieldControl(() => ({
|
|
125
|
+
wrapped: hasField.value,
|
|
126
|
+
hasMessage: !!props.error || !!props.hint,
|
|
127
|
+
id: typeof attrs.id === "string" ? attrs.id : undefined,
|
|
128
|
+
invalid: props.invalid || !!props.error || stuck.value,
|
|
129
|
+
required: props.required,
|
|
130
|
+
disabled: props.disabled,
|
|
131
|
+
}))
|
|
132
|
+
|
|
133
|
+
const describedBy = computed(
|
|
134
|
+
() =>
|
|
135
|
+
[
|
|
136
|
+
field.value.describedBy,
|
|
137
|
+
hasField.value && field.value.id ? `${field.value.id}-notice` : undefined,
|
|
138
|
+
standalone.value && notice.message.value ? noticeId : undefined,
|
|
139
|
+
]
|
|
140
|
+
.filter(Boolean)
|
|
141
|
+
.join(" ") || undefined,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
const rootAttrs = computed(() => {
|
|
145
|
+
const { class: _c, style: _s, id: _i, ...rest } = attrs
|
|
146
|
+
return rest
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
function explain(rejected: TagRejection): string {
|
|
150
|
+
if (rejected.reason === "invalid") return rejected.message ?? ""
|
|
151
|
+
if (rejected.reason === "max") return text.value.max.replace("{max}", String(props.max))
|
|
152
|
+
return text.value.duplicate.replace("{tag}", rejected.value)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const inputEl = ref<HTMLInputElement | null>(null)
|
|
156
|
+
|
|
157
|
+
/*
|
|
158
|
+
* The box grows a row at a time as tags wrap, and shrinks back when they go:
|
|
159
|
+
* eased rather than snapped, so the fields below glide instead of jumping.
|
|
160
|
+
*/
|
|
161
|
+
const { el: boxEl, tweening, resizing } = useShift({ axis: "height" })
|
|
162
|
+
|
|
163
|
+
/*
|
|
164
|
+
* Keys follow the tag, not its position: keyed by index, removing the first
|
|
165
|
+
* tag re-keyed every later one and they all replayed their entry. A repeated
|
|
166
|
+
* tag (duplicates "allow") is told apart by which occurrence it is.
|
|
167
|
+
*/
|
|
168
|
+
const keyed = computed(() => {
|
|
169
|
+
const seen = new Map<string, number>()
|
|
170
|
+
return props.modelValue.map((tag) => {
|
|
171
|
+
const n = seen.get(tag) ?? 0
|
|
172
|
+
seen.set(tag, n + 1)
|
|
173
|
+
return { tag, key: `${n}:${tag}` }
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
/** Draw the eye to a tag that was just typed again. */
|
|
178
|
+
function pulse(tag: string): void {
|
|
179
|
+
if (prefersReducedMotion()) return
|
|
180
|
+
const chip = boxEl.value?.querySelector<HTMLElement>(`[data-tag-key="${CSS.escape(`0:${tag}`)}"]`)
|
|
181
|
+
chip?.animate(
|
|
182
|
+
[
|
|
183
|
+
{ transform: "scale(1)", boxShadow: "0 0 0 0 transparent" },
|
|
184
|
+
{ transform: "scale(1.08)", boxShadow: "0 0 0 2px var(--color-ring)" },
|
|
185
|
+
{ transform: "scale(1)", boxShadow: "0 0 0 0 transparent" },
|
|
186
|
+
],
|
|
187
|
+
{ duration: 420, easing: "ease-out" },
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Add whatever `raw` splits into; returns the text that should stay typed. */
|
|
192
|
+
function add(raw: string): string {
|
|
193
|
+
const parts = splitTags(raw, props.delimiter)
|
|
194
|
+
if (!parts.length) return ""
|
|
195
|
+
const { tags, rejected, ignored } = addTags(props.modelValue, parts, {
|
|
196
|
+
max: props.max,
|
|
197
|
+
duplicates: props.duplicates,
|
|
198
|
+
validate: props.validate,
|
|
199
|
+
normalize: props.normalize,
|
|
200
|
+
})
|
|
201
|
+
if (tags.length !== props.modelValue.length) {
|
|
202
|
+
const added = tags.slice(props.modelValue.length)
|
|
203
|
+
announcement.value = added.map((t) => text.value.added.replace("{tag}", t)).join(", ")
|
|
204
|
+
emit("update:modelValue", tags)
|
|
205
|
+
}
|
|
206
|
+
stuck.value = rejected.length > 0
|
|
207
|
+
const first = rejected[0]
|
|
208
|
+
if (first) {
|
|
209
|
+
say(explain(first), { tone: first.reason === "invalid" ? "danger" : "info", duration: 0 })
|
|
210
|
+
emit("reject", rejected)
|
|
211
|
+
} else if (ignored.length) {
|
|
212
|
+
say(text.value.duplicate.replace("{tag}", ignored[0]!))
|
|
213
|
+
} else {
|
|
214
|
+
unsay()
|
|
215
|
+
}
|
|
216
|
+
for (const r of rejected) if (r.reason === "duplicate") pulse(props.normalize ? props.normalize(r.value) : r.value)
|
|
217
|
+
for (const tag of ignored) pulse(tag)
|
|
218
|
+
return rejected.map((r) => r.value).join(joiner.value)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Rejected parts go back into the field joined by a delimiter, so committing
|
|
222
|
+
// them again splits them the same way.
|
|
223
|
+
const joiner = computed(() => {
|
|
224
|
+
const d = props.delimiter
|
|
225
|
+
const visible = d instanceof RegExp ? undefined : (typeof d === "string" ? [d] : d).find((x) => x.trim())
|
|
226
|
+
return visible ? `${visible} ` : " "
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
function commit(el: HTMLInputElement): void {
|
|
230
|
+
el.value = add(el.value)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function isDelimiter(data: string): boolean {
|
|
234
|
+
const d = props.delimiter
|
|
235
|
+
if (d instanceof RegExp) return new RegExp(d.source, d.flags.replace(/[gy]/g, "")).test(data)
|
|
236
|
+
return (typeof d === "string" ? [d] : d).includes(data)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function onKeydown(event: KeyboardEvent): void {
|
|
240
|
+
if (event.isComposing || !(event.target instanceof HTMLInputElement)) return
|
|
241
|
+
if (event.key === "Enter" && event.target.value.trim()) {
|
|
242
|
+
event.preventDefault()
|
|
243
|
+
commit(event.target)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function onBeforeInput(event: InputEvent): void {
|
|
248
|
+
if (event.isComposing || !(event.target instanceof HTMLInputElement)) return
|
|
249
|
+
if (event.inputType === "insertText" && event.data && isDelimiter(event.data)) {
|
|
250
|
+
event.preventDefault()
|
|
251
|
+
commit(event.target)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function onInput(): void {
|
|
256
|
+
if (!stuck.value) return
|
|
257
|
+
stuck.value = false
|
|
258
|
+
unsay()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function onPaste(event: ClipboardEvent): void {
|
|
262
|
+
const pasted = event.clipboardData?.getData("text") ?? ""
|
|
263
|
+
if (!(event.target instanceof HTMLInputElement) || !pasted) return
|
|
264
|
+
if (splitTags(pasted, props.delimiter).length < 2) return
|
|
265
|
+
event.preventDefault()
|
|
266
|
+
const el = event.target
|
|
267
|
+
const typed = el.value.slice(0, el.selectionStart ?? el.value.length) + pasted + el.value.slice(el.selectionEnd ?? el.value.length)
|
|
268
|
+
el.value = add(typed)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function onBlur(event: FocusEvent): void {
|
|
272
|
+
emit("blur", event)
|
|
273
|
+
if (props.addOnBlur && event.target instanceof HTMLInputElement && event.target.value.trim()) commit(event.target)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function onModel(value: (string | Record<string, unknown>)[] | null): void {
|
|
277
|
+
const next = (value ?? []).map(String)
|
|
278
|
+
const removed = props.modelValue.filter((t) => !next.includes(t))
|
|
279
|
+
if (removed.length) announcement.value = removed.map((t) => text.value.removed.replace("{tag}", t)).join(", ")
|
|
280
|
+
stuck.value = false
|
|
281
|
+
unsay()
|
|
282
|
+
emit("update:modelValue", next)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
defineExpose({ focus: () => inputEl.value?.focus(), add: (value: string) => add(value) })
|
|
286
|
+
</script>
|
|
287
|
+
|
|
288
|
+
<template>
|
|
289
|
+
<component
|
|
290
|
+
:is="hasField ? LpFormField : 'div'"
|
|
291
|
+
:ref="bindField"
|
|
292
|
+
:label="hasField ? label : undefined"
|
|
293
|
+
:hint="hasField ? hint : undefined"
|
|
294
|
+
:error="hasField ? error : undefined"
|
|
295
|
+
:required="hasField ? required : undefined"
|
|
296
|
+
:id="hasField ? field.id : undefined"
|
|
297
|
+
:class="hasField ? attrs.class : 'contents'"
|
|
298
|
+
:style="hasField ? attrs.style : undefined"
|
|
299
|
+
>
|
|
300
|
+
<div class="relative">
|
|
301
|
+
<TagsInputRoot
|
|
302
|
+
v-bind="rootAttrs"
|
|
303
|
+
:model-value="modelValue"
|
|
304
|
+
:disabled="field.disabled"
|
|
305
|
+
:required="field.required"
|
|
306
|
+
:id="field.id"
|
|
307
|
+
:delimiter="/(?!)/"
|
|
308
|
+
data-lp-ring-owner
|
|
309
|
+
:ref="(c) => (boxEl = (c as { $el?: HTMLElement } | null)?.$el ?? null)"
|
|
310
|
+
:class="
|
|
311
|
+
mergeClass(
|
|
312
|
+
'flex min-h-(--size-control-md) w-full flex-wrap items-center gap-1.5 rounded-control border border-line bg-surface-soft px-2 py-1.5 text-sm text-ink transition-colors duration-[var(--duration-fast)] focus-within:border-brand focus-within:ring-2 focus-within:ring-ring has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-55',
|
|
313
|
+
field.invalid ? 'border-danger focus-within:border-danger focus-within:ring-danger-soft' : '',
|
|
314
|
+
hasField ? '' : (attrs.class as string),
|
|
315
|
+
tweening
|
|
316
|
+
? 'transition-[height,border-color,box-shadow] duration-fast ease-[var(--ease-emphasized)] motion-reduce:transition-none'
|
|
317
|
+
: '',
|
|
318
|
+
resizing ? 'overflow-hidden' : '',
|
|
319
|
+
)
|
|
320
|
+
"
|
|
321
|
+
:style="hasField ? undefined : attrs.style"
|
|
322
|
+
@update:model-value="onModel"
|
|
323
|
+
>
|
|
324
|
+
<!-- Tags grow in when added and shrink out when removed; the ones
|
|
325
|
+
already there on mount stay still (no `appear`). -->
|
|
326
|
+
<TransitionGroup
|
|
327
|
+
enter-active-class="animate-[chip-in_var(--duration-fast)_var(--ease-emphasized)] motion-reduce:animate-none"
|
|
328
|
+
leave-active-class="animate-[chip-out_var(--duration-fast)_ease] motion-reduce:animate-none"
|
|
329
|
+
>
|
|
330
|
+
<TagsInputItem
|
|
331
|
+
v-for="{ tag, key } in keyed"
|
|
332
|
+
:key="key"
|
|
333
|
+
:data-tag-key="key"
|
|
334
|
+
:value="tag"
|
|
335
|
+
class="inline-flex max-w-full items-center gap-1 rounded-pill bg-surface-raised py-0.5 pr-1 pl-2.5 text-xs font-medium text-ink outline-none data-[state=active]:ring-2 data-[state=active]:ring-ring"
|
|
336
|
+
>
|
|
337
|
+
<TagsInputItemText class="truncate" />
|
|
338
|
+
<TagsInputItemDelete
|
|
339
|
+
:aria-labelledby="undefined"
|
|
340
|
+
:aria-label="text.remove.replace('{tag}', tag)"
|
|
341
|
+
class="grid size-4 shrink-0 place-items-center rounded-pill text-muted transition-colors hover:bg-ink/10 hover:text-ink"
|
|
342
|
+
>
|
|
343
|
+
<LpIcon name="lucide:x" :size="12" />
|
|
344
|
+
</TagsInputItemDelete>
|
|
345
|
+
</TagsInputItem>
|
|
346
|
+
</TransitionGroup>
|
|
347
|
+
<TagsInputInput
|
|
348
|
+
:ref="(c) => (inputEl = (c as { $el?: HTMLInputElement } | null)?.$el ?? null)"
|
|
349
|
+
:placeholder="modelValue.length ? undefined : placeholder"
|
|
350
|
+
:aria-invalid="field.invalid || undefined"
|
|
351
|
+
:aria-describedby="describedBy"
|
|
352
|
+
class="min-w-24 flex-1 bg-transparent px-1 outline-none placeholder:text-muted"
|
|
353
|
+
@keydown="onKeydown"
|
|
354
|
+
@beforeinput="onBeforeInput"
|
|
355
|
+
@input="onInput"
|
|
356
|
+
@paste="onPaste"
|
|
357
|
+
@blur="onBlur"
|
|
358
|
+
/>
|
|
359
|
+
</TagsInputRoot>
|
|
360
|
+
<LpFieldNotice v-if="standalone" :notice="notice" :id="noticeId" />
|
|
361
|
+
</div>
|
|
362
|
+
<span class="sr-only" aria-live="polite">{{ announcement }}</span>
|
|
363
|
+
</component>
|
|
364
|
+
</template>
|
|
@@ -34,9 +34,9 @@ const props = withDefaults(
|
|
|
34
34
|
cycleThemes?: (string | TokenSet)[]
|
|
35
35
|
/**
|
|
36
36
|
* Trigger style:
|
|
37
|
-
* - "swatch" (default): a
|
|
37
|
+
* - "swatch" (default): a round chip previewing the theme's surface and brand.
|
|
38
38
|
* - "icon": a sun/moon icon following the active theme's mode.
|
|
39
|
-
* - "pill":
|
|
39
|
+
* - "pill": the chip and the theme name in a rounded pill.
|
|
40
40
|
*/
|
|
41
41
|
variant?: "swatch" | "icon" | "pill"
|
|
42
42
|
/** Show the active theme's name next to the trigger (forced on for "pill"). */
|
|
@@ -104,14 +104,12 @@ onMounted(() => {
|
|
|
104
104
|
})
|
|
105
105
|
|
|
106
106
|
const SIZES = { sm: "size-7", md: "size-8", lg: "size-9" } as const
|
|
107
|
+
const HEIGHTS = { sm: "h-7", md: "h-8", lg: "h-9" } as const
|
|
107
108
|
const ICON_SIZE = { sm: 15, md: 16, lg: 18 } as const
|
|
109
|
+
const CHIP = { sm: "size-3.5", md: "size-4", lg: "size-[18px]" } as const
|
|
108
110
|
|
|
109
111
|
const wantsLabel = computed(() => props.showLabel || props.variant === "pill")
|
|
110
112
|
|
|
111
|
-
// Labelless icon variant renders as a plain square icon button (no border/fill),
|
|
112
|
-
// so it sits inline with other toolbar icon buttons. Other variants stay framed.
|
|
113
|
-
const bareIcon = computed(() => props.variant === "icon" && !wantsLabel.value)
|
|
114
|
-
|
|
115
113
|
// Apply a theme through the kit's self-managed setter (it persists + updates
|
|
116
114
|
// the shared `current`), then notify. The reveal animates from the click point.
|
|
117
115
|
function applyTheme(t: TokenSet, ev?: MouseEvent) {
|
|
@@ -228,30 +226,28 @@ const triggerLabel = computed(
|
|
|
228
226
|
type="button"
|
|
229
227
|
:aria-label="triggerLabel"
|
|
230
228
|
:title="triggerLabel"
|
|
231
|
-
class="inline-flex items-center justify-center gap-2
|
|
229
|
+
class="inline-flex shrink-0 items-center justify-center gap-2 outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring"
|
|
232
230
|
:class="
|
|
233
|
-
|
|
234
|
-
? [
|
|
235
|
-
|
|
236
|
-
'border border-line bg-surface-raised
|
|
237
|
-
wantsLabel ? 'py-1 pr-2.5' : '',
|
|
231
|
+
wantsLabel
|
|
232
|
+
? [
|
|
233
|
+
HEIGHTS[size],
|
|
234
|
+
'rounded-full border border-line bg-surface-raised pl-2 pr-3 text-ink hover:border-line-strong hover:bg-surface-soft',
|
|
238
235
|
]
|
|
236
|
+
: [SIZES[size], 'rounded-control text-muted hover:bg-surface-soft hover:text-ink']
|
|
239
237
|
"
|
|
240
238
|
@click="cycle"
|
|
241
239
|
>
|
|
242
|
-
<!--
|
|
243
|
-
than the theme object, so the server's
|
|
244
|
-
cookie) and the client's agree
|
|
240
|
+
<!-- A preview of the applied theme: its surface and brand halves. Read from
|
|
241
|
+
the applied variables rather than the theme object, so the server's
|
|
242
|
+
render (painted from the cookie) and the client's agree. -->
|
|
245
243
|
<span
|
|
246
244
|
v-if="variant !== 'icon'"
|
|
247
|
-
class="
|
|
248
|
-
:class="
|
|
249
|
-
style="background: var(--color-surface)"
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
<!-- icon: sun/moon by mode, chosen by CSS from the applied theme's mode -->
|
|
254
|
-
<span v-else class="grid shrink-0 place-items-center" :class="SIZES[size]">
|
|
245
|
+
class="shrink-0 rounded-full shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--color-ink)_22%,transparent)]"
|
|
246
|
+
:class="CHIP[size]"
|
|
247
|
+
style="background: linear-gradient(135deg, var(--color-surface) 50%, var(--color-brand) 50%)"
|
|
248
|
+
/>
|
|
249
|
+
<!-- sun/moon by mode, chosen by CSS from the applied theme's mode -->
|
|
250
|
+
<span v-else class="grid shrink-0 place-items-center">
|
|
255
251
|
<LpIcon
|
|
256
252
|
name="lucide:moon"
|
|
257
253
|
:size="ICON_SIZE[size]"
|