refactor: hoppscotch ui (#2887)

* feat: hopp ui initialized

* feat: button components added

* feat: windi css integration

* chore: package removed from hopp ui

* feat: storybook added

* feat: move all smart components hoppscotch-ui

* fix: import issue from components/smart

* fix: env input component import

* feat: add hoppui to windicss config

* fix: remove storybook

* feat: move components from hoppscotch-ui

* feat: storybook added

* feat: storybook progress

* feat: themeing storybook

* feat: add stories

* chore: package updated

* chore: stories added

* feat: stories added

* feat: stories added

* feat: icons resolved

* feat: i18n composable resolved

* feat: histoire added

* chore: resolved prettier issue

* feat: radio story added

* feat: story added for all components

* feat: new components added to stories

* fix: resolved issues

* feat: readme.md added

* feat: context/provider added

* chore: removed app component registry

* chore: remove importing of all components in hopp-ui to allow code splitting

* chore: fix vite config errors

* chore: jsdoc added

* chore: any replaced with smart-item

* chore: i18n added to ui components

* chore: clean up - removed a duplicate button

---------

Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
This commit is contained in:
Anwarul Islam
2023-01-28 08:57:00 +06:00
committed by GitHub
parent 9d7052c626
commit 0fcda0be1a
65 changed files with 2961 additions and 214 deletions

View File

@@ -0,0 +1,562 @@
* {
@apply backface-hidden;
@apply before:backface-hidden;
@apply after:backface-hidden;
@apply selection:bg-accentDark;
@apply selection:text-accentContrast;
}
:root {
@apply antialiased;
accent-color: var(--accent-color);
font-variant-ligatures: common-ligatures;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
@apply border-solid border-l border-dividerLight border-t-0 border-b-0 border-r-0;
}
::-webkit-scrollbar-thumb {
@apply bg-divider bg-clip-content;
@apply rounded-full;
@apply border-solid border-transparent border-4;
@apply hover:bg-dividerDark;
@apply hover:bg-clip-content;
}
::-webkit-scrollbar {
@apply w-4;
@apply h-0;
}
input::placeholder,
textarea::placeholder,
.cm-placeholder {
@apply text-secondary;
@apply opacity-35;
}
input,
textarea {
@apply text-secondaryDark;
@apply font-medium;
}
html {
scroll-behavior: smooth;
}
body {
@apply bg-primary;
@apply text-secondary text-body;
@apply font-medium;
@apply select-none;
@apply overflow-x-hidden;
@apply leading-body;
animation: fade 300ms forwards;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
}
@keyframes fade {
0% {
@apply opacity-0;
}
100% {
@apply opacity-100;
}
}
.fade-enter-active,
.fade-leave-active {
@apply transition-opacity;
}
.fade-enter-from,
.fade-leave-to {
@apply opacity-0;
}
.slide-enter-active,
.slide-leave-active {
@apply transition;
@apply duration-300;
}
.slide-enter-from,
.slide-leave-to {
@apply transform;
@apply translate-x-full;
}
.bounce-enter-active,
.bounce-leave-active {
@apply transition;
}
.bounce-enter-from,
.bounce-leave-to {
@apply transform;
@apply scale-95;
}
.svg-icons {
@apply flex-shrink-0;
@apply overflow-hidden;
height: var(--line-height-body);
width: var(--line-height-body);
}
a {
@apply inline-flex;
@apply text-current;
@apply no-underline;
@apply transition;
@apply leading-body;
@apply focus:outline-none;
&.link {
@apply items-center;
@apply py-0.5 px-1;
@apply -my-0.5 -mx-1;
@apply text-accent;
@apply rounded;
@apply hover:text-accentDark;
@apply focus-visible:ring;
@apply focus-visible:ring-accent;
@apply focus-visible:text-accentDark;
}
}
.cm-tooltip {
.tippy-box {
@apply shadow-none;
@apply fixed;
@apply inline-flex;
@apply -mt-6;
}
}
.tippy-box[data-theme~="tooltip"] {
@apply bg-tooltip;
@apply border-solid border-tooltip;
@apply rounded;
@apply shadow;
.tippy-content {
@apply flex;
@apply text-tiny text-primary;
@apply font-semibold;
@apply py-1 px-2;
@apply truncate;
@apply leading-normal;
@apply items-center;
kbd {
@apply hidden;
@apply font-sans;
@apply bg-gray-500/45;
@apply text-primaryLight;
@apply rounded-sm;
@apply px-1;
@apply my-0 ml-1;
@apply truncate;
@apply sm:inline-flex;
}
.env-icon {
@apply transition;
@apply inline-flex;
@apply items-center;
}
}
.tippy-svg-arrow {
svg:first-child {
@apply fill-tooltip;
}
svg:last-child {
@apply fill-tooltip;
}
}
}
.tippy-box[data-theme~="popover"] {
@apply bg-popover;
@apply border-solid border-dividerDark;
@apply rounded;
@apply shadow-lg;
.tippy-content {
@apply flex flex-col;
@apply max-h-56;
@apply items-stretch;
@apply overflow-y-auto;
@apply text-secondary text-body;
@apply p-2;
@apply leading-normal;
@apply focus:outline-none;
scroll-behavior: smooth;
}
.tippy-svg-arrow {
svg:first-child {
@apply fill-dividerDark;
}
svg:last-child {
@apply fill-popover;
}
}
}
[data-v-tippy] {
@apply flex flex-1;
}
[interactive] > div {
@apply flex flex-1;
@apply h-full;
}
hr {
@apply border-b border-dividerLight;
@apply my-2;
}
.heading {
@apply font-bold;
@apply text-secondaryDark text-lg;
@apply tracking-tight;
}
.input,
.select,
.textarea {
@apply flex;
@apply w-full;
@apply py-2 px-4;
@apply bg-transparent;
@apply rounded;
@apply text-secondaryDark;
@apply border border-divider;
@apply focus-visible:border-dividerDark;
}
input,
select,
textarea,
button {
@apply truncate;
@apply transition;
@apply text-body;
@apply leading-body;
@apply focus:outline-none;
@apply disabled:cursor-not-allowed;
}
.input[type="file"],
.input[type="radio"],
#installPWA {
@apply hidden;
}
.floating-input ~ label {
@apply absolute;
@apply px-2 py-0.5;
@apply m-2;
@apply rounded;
@apply transition;
@apply origin-top-left;
}
.floating-input:focus-within ~ label,
.floating-input:not(:placeholder-shown) ~ label {
@apply bg-primary;
@apply transform;
@apply origin-top-left;
@apply scale-75;
@apply translate-x-1 -translate-y-4;
}
.floating-input:focus-within ~ label {
@apply text-secondaryDark;
}
.floating-input ~ .end-actions {
@apply absolute;
@apply right-0.2;
@apply inset-y-0;
@apply flex;
@apply items-center;
}
.floating-input:has(~ .end-actions) {
@apply pr-12;
}
pre.ace_editor {
@apply font-mono;
@apply resize-none;
@apply z-0;
}
.select {
@apply appearance-none;
@apply cursor-pointer;
&::-ms-expand {
@apply hidden;
}
}
.select-wrapper {
@apply flex flex-1;
@apply relative;
@apply after:absolute;
@apply after:flex;
@apply after:inset-y-0;
@apply after:items-center;
@apply after:justify-center;
@apply after:pointer-events-none;
@apply after:font-icon;
@apply after:text-secondaryLight;
@apply after:right-3;
@apply after:content-["\e313"];
}
.info-response {
@apply text-pink-500;
}
.success-response {
@apply text-green-500;
}
.redir-response {
@apply text-yellow-500;
}
.cl-error-response {
@apply text-red-500;
}
.sv-error-response {
@apply text-red-600;
}
.missing-data-response {
@apply text-secondaryLight;
}
.toasted-container {
@apply max-w-md;
.toasted {
&.toasted-primary {
@apply px-4 py-2;
@apply bg-tooltip;
@apply border-secondaryDark;
@apply text-primary text-body;
@apply justify-between;
@apply shadow-lg;
@apply font-semibold;
@apply transition;
@apply leading-body;
@apply sm:rounded;
@apply sm:border;
.action {
@apply relative;
@apply flex flex-shrink-0;
@apply text-body;
@apply px-4;
@apply my-1;
@apply ml-auto;
@apply normal-case;
@apply font-semibold;
@apply leading-body;
@apply tracking-normal;
@apply rounded;
@apply last:ml-4;
@apply sm:ml-8;
@apply before:absolute;
@apply before:bg-current;
@apply before:opacity-10;
@apply before:inset-0;
@apply before:transition;
@apply before:content-DEFAULT;
@apply hover:no-underline;
@apply hover:before:opacity-20;
}
}
&.info {
@apply bg-accent;
@apply text-accentContrast;
@apply border-accentDark;
}
&.error {
@apply bg-red-200;
@apply text-red-800;
@apply border-red-400;
}
&.success {
@apply bg-green-200;
@apply text-green-800;
@apply border-green-400;
}
}
}
.smart-splitter .splitpanes__splitter {
@apply relative;
@apply bg-primaryLight;
@apply before:absolute;
@apply before:inset-0;
@apply before:bg-accentLight;
@apply before:opacity-0;
@apply before:z-20;
@apply before:transition;
@apply before:content-DEFAULT;
@apply after:absolute;
@apply after:inset-0;
@apply after:z-20;
@apply after:transition;
@apply after:flex;
@apply after:items-center;
@apply after:justify-center;
@apply after:text-dividerDark;
@apply after:font-icon;
@apply hover:before:opacity-100;
@apply hover:after:text-accentDark;
}
.no-splitter .splitpanes__splitter {
@apply relative;
@apply bg-primaryLight;
}
.smart-splitter.splitpanes--vertical > .splitpanes__splitter {
@apply w-1;
@apply before:-left-0.5;
@apply before:-right-0.5;
@apply before:h-full;
@apply after:content-["\e5d4"];
}
.smart-splitter.splitpanes--horizontal > .splitpanes__splitter {
@apply h-1;
@apply before:-top-0.5;
@apply before:-bottom-0.5;
@apply before:w-full;
@apply after:content-["\e5d3"];
}
.no-splitter.splitpanes--vertical > .splitpanes__splitter {
@apply w-0.5;
@apply pointer-events-none;
}
.no-splitter.splitpanes--horizontal > .splitpanes__splitter {
@apply h-0.5;
@apply pointer-events-none;
}
.cm-focused {
@apply select-auto;
@apply outline-none #{!important};
.cm-activeLine {
@apply bg-primaryLight;
}
.cm-activeLineGutter {
@apply bg-primaryDark;
}
}
.cm-editor {
.cm-line::selection {
@apply bg-accentDark #{!important};
@apply text-accentContrast #{!important};
}
.cm-line ::selection {
@apply bg-accentDark #{!important};
@apply text-accentContrast #{!important};
}
}
.shortcut-key {
@apply inline-flex;
@apply font-sans;
@apply text-tiny;
@apply bg-divider;
@apply rounded;
@apply ml-2;
@apply px-1;
@apply min-w-5;
@apply min-h-5;
@apply items-center;
@apply justify-center;
@apply border border-dividerDark;
@apply shadow-sm;
@apply <sm:hidden;
}
.capitalize-first {
@apply first-letter:capitalize;
}
details {
@apply select-none;
}
details summary::-webkit-details-marker {
@apply hidden;
}
details summary .indicator {
@apply transition;
}
details[open] summary .indicator {
@apply transform;
@apply rotate-90;
}
@media (max-width: 767px) {
main {
margin-bottom: env(safe-area-inset-bottom);
}
}
.env-highlight {
* {
@apply text-accentContrast;
}
}
#nprogress .bar {
@apply bg-accent #{!important};
}
.color-picker[type="color"] {
@apply appearance-none;
}
.color-picker[type="color"]::-webkit-color-swatch-wrapper {
@apply rounded;
@apply p-0;
}
.color-picker[type="color"]::-webkit-color-swatch {
@apply rounded;
@apply border-0;
}

View File

@@ -0,0 +1,311 @@
@mixin base-theme {
--font-sans: "Inter", sans-serif;
--font-mono: "Roboto Mono", monospace;
--font-icon: "Material Icons";
--font-size-tiny: calc(var(--font-size-body) - 0.062rem);
}
@mixin dark-theme {
--primary-color: theme("colors.neutral.900");
--primary-light-color: theme("colors.dark.600");
--primary-dark-color: theme("colors.neutral.800");
--primary-contrast-color: #161616;
--secondary-color: theme("colors.neutral.400");
--secondary-light-color: theme("colors.neutral.500");
--secondary-dark-color: theme("colors.neutral.100");
--divider-color: theme("colors.neutral.800");
--divider-light-color: theme("colors.dark.500");
--divider-dark-color: theme("colors.dark.300");
--error-color: theme("colors.stone.800");
--tooltip-color: theme("colors.neutral.100");
--popover-color: theme("colors.dark.700");
--editor-theme: "merbivore_soft";
}
@mixin light-theme {
--primary-color: theme("colors.white");
--primary-light-color: theme("colors.neutral.50");
--primary-dark-color: theme("colors.neutral.100");
--primary-contrast-color: #fefefe;
--secondary-color: theme("colors.neutral.500");
--secondary-light-color: theme("colors.neutral.400");
--secondary-dark-color: theme("colors.neutral.900");
--divider-color: theme("colors.gray.100");
--divider-light-color: theme("colors.neutral.100");
--divider-dark-color: theme("colors.neutral.300");
--error-color: theme("colors.yellow.100");
--tooltip-color: theme("colors.neutral.800");
--popover-color: theme("colors.white");
--editor-theme: "textmate";
}
@mixin black-theme {
--primary-color: theme("colors.dark.900");
--primary-light-color: theme("colors.neutral.900");
--primary-dark-color: theme("colors.dark.800");
--primary-contrast-color: #0e0e0e;
--secondary-color: theme("colors.neutral.400");
--secondary-light-color: theme("colors.neutral.500");
--secondary-dark-color: theme("colors.neutral.100");
--divider-color: theme("colors.neutral.800");
--divider-light-color: theme("colors.dark.800");
--divider-dark-color: theme("colors.dark.300");
--error-color: theme("colors.stone.900");
--tooltip-color: theme("colors.neutral.100");
--popover-color: theme("colors.dark.600");
--editor-theme: "twilight";
}
@mixin dark-editor-theme {
--editor-type-color: theme("colors.purple.400");
--editor-name-color: theme("colors.blue.400");
--editor-operator-color: theme("colors.indigo.400");
--editor-invalid-color: theme("colors.red.400");
--editor-separator-color: theme("colors.gray.400");
--editor-meta-color: theme("colors.gray.400");
--editor-variable-color: theme("colors.green.400");
--editor-link-color: theme("colors.cyan.400");
--editor-process-color: theme("colors.fuchsia.400");
--editor-constant-color: theme("colors.violet.400");
--editor-keyword-color: theme("colors.pink.400");
}
@mixin light-editor-theme {
--editor-type-color: theme("colors.purple.600");
--editor-name-color: theme("colors.red.600");
--editor-operator-color: theme("colors.indigo.600");
--editor-invalid-color: theme("colors.red.600");
--editor-separator-color: theme("colors.gray.600");
--editor-meta-color: theme("colors.gray.600");
--editor-variable-color: theme("colors.green.600");
--editor-link-color: theme("colors.cyan.600");
--editor-process-color: theme("colors.blue.600");
--editor-constant-color: theme("colors.fuchsia.600");
--editor-keyword-color: theme("colors.pink.600");
}
@mixin black-editor-theme {
--editor-type-color: theme("colors.purple.400");
--editor-name-color: theme("colors.fuchsia.400");
--editor-operator-color: theme("colors.indigo.400");
--editor-invalid-color: theme("colors.red.400");
--editor-separator-color: theme("colors.gray.400");
--editor-meta-color: theme("colors.gray.400");
--editor-variable-color: theme("colors.green.400");
--editor-link-color: theme("colors.cyan.400");
--editor-process-color: theme("colors.violet.400");
--editor-constant-color: theme("colors.blue.400");
--editor-keyword-color: theme("colors.pink.400");
}
@mixin green-theme {
--accent-color: theme("colors.green.500");
--accent-light-color: theme("colors.green.400");
--accent-dark-color: theme("colors.green.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.green.200");
--gradient-via-color: theme("colors.green.400");
--gradient-to-color: theme("colors.green.600");
}
@mixin teal-theme {
--accent-color: theme("colors.teal.500");
--accent-light-color: theme("colors.teal.400");
--accent-dark-color: theme("colors.teal.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.teal.200");
--gradient-via-color: theme("colors.teal.400");
--gradient-to-color: theme("colors.teal.600");
}
@mixin blue-theme {
--accent-color: theme("colors.blue.500");
--accent-light-color: theme("colors.blue.400");
--accent-dark-color: theme("colors.blue.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.blue.200");
--gradient-via-color: theme("colors.blue.400");
--gradient-to-color: theme("colors.blue.600");
}
@mixin indigo-theme {
--accent-color: theme("colors.indigo.500");
--accent-light-color: theme("colors.indigo.400");
--accent-dark-color: theme("colors.indigo.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.indigo.200");
--gradient-via-color: theme("colors.indigo.400");
--gradient-to-color: theme("colors.indigo.600");
}
@mixin purple-theme {
--accent-color: theme("colors.purple.500");
--accent-light-color: theme("colors.purple.400");
--accent-dark-color: theme("colors.purple.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.purple.200");
--gradient-via-color: theme("colors.purple.400");
--gradient-to-color: theme("colors.purple.600");
}
@mixin yellow-theme {
--accent-color: theme("colors.yellow.500");
--accent-light-color: theme("colors.yellow.400");
--accent-dark-color: theme("colors.yellow.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.yellow.200");
--gradient-via-color: theme("colors.yellow.400");
--gradient-to-color: theme("colors.yellow.600");
}
@mixin orange-theme {
--accent-color: theme("colors.orange.500");
--accent-light-color: theme("colors.orange.400");
--accent-dark-color: theme("colors.orange.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.orange.200");
--gradient-via-color: theme("colors.orange.400");
--gradient-to-color: theme("colors.orange.600");
}
@mixin red-theme {
--accent-color: theme("colors.red.500");
--accent-light-color: theme("colors.red.400");
--accent-dark-color: theme("colors.red.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.red.200");
--gradient-via-color: theme("colors.red.400");
--gradient-to-color: theme("colors.red.600");
}
@mixin pink-theme {
--accent-color: theme("colors.pink.500");
--accent-light-color: theme("colors.pink.400");
--accent-dark-color: theme("colors.pink.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.pink.200");
--gradient-via-color: theme("colors.pink.400");
--gradient-to-color: theme("colors.pink.600");
}
:root {
@include base-theme;
@include dark-theme;
@include green-theme;
@include dark-editor-theme;
}
:root.light {
@include light-theme;
@include light-editor-theme;
}
:root.dark {
@include dark-theme;
@include dark-editor-theme;
}
:root.black {
@include black-theme;
@include black-editor-theme;
}
:root[data-accent="blue"] {
@include blue-theme;
}
:root[data-accent="green"] {
@include green-theme;
}
:root[data-accent="teal"] {
@include teal-theme;
}
:root[data-accent="indigo"] {
@include indigo-theme;
}
:root[data-accent="purple"] {
@include purple-theme;
}
:root[data-accent="orange"] {
@include orange-theme;
}
:root[data-accent="pink"] {
@include pink-theme;
}
:root[data-accent="red"] {
@include red-theme;
}
:root[data-accent="yellow"] {
@include yellow-theme;
}
@mixin font-small {
--font-size-body: 0.75rem;
--line-height-body: 1rem;
--upper-primary-sticky-fold: 4.125rem;
--upper-secondary-sticky-fold: 6.188rem;
--upper-tertiary-sticky-fold: 8.25rem;
--upper-mobile-primary-sticky-fold: 6.625rem;
--upper-mobile-secondary-sticky-fold: 8.688rem;
--upper-mobile-sticky-fold: 10.75rem;
--upper-mobile-tertiary-sticky-fold: 8.25rem;
--lower-primary-sticky-fold: 3rem;
--lower-secondary-sticky-fold: 5.063rem;
--lower-tertiary-sticky-fold: 7.125rem;
--sidebar-primary-sticky-fold: 2rem;
}
@mixin font-medium {
--font-size-body: 0.875rem;
--line-height-body: 1.25rem;
--upper-primary-sticky-fold: 4.375rem;
--upper-secondary-sticky-fold: 6.688rem;
--upper-tertiary-sticky-fold: 9rem;
--upper-mobile-primary-sticky-fold: 7.125rem;
--upper-mobile-secondary-sticky-fold: 9.438rem;
--upper-mobile-sticky-fold: 11.75rem;
--upper-mobile-tertiary-sticky-fold: 9rem;
--lower-primary-sticky-fold: 3.25rem;
--lower-secondary-sticky-fold: 5.563rem;
--lower-tertiary-sticky-fold: 7.875rem;
--sidebar-primary-sticky-fold: 2.25rem;
}
@mixin font-large {
--font-size-body: 1rem;
--line-height-body: 1.5rem;
--upper-primary-sticky-fold: 4.625rem;
--upper-secondary-sticky-fold: 7.188rem;
--upper-tertiary-sticky-fold: 9.75rem;
--upper-mobile-primary-sticky-fold: 7.625rem;
--upper-mobile-secondary-sticky-fold: 10.188rem;
--upper-mobile-sticky-fold: 12.75rem;
--upper-mobile-tertiary-sticky-fold: 9.75rem;
--lower-primary-sticky-fold: 3.5rem;
--lower-secondary-sticky-fold: 6.063rem;
--lower-tertiary-sticky-fold: 8.625rem;
--sidebar-primary-sticky-fold: 2.5rem;
}
:root[data-font-size="small"] {
@include font-small;
}
:root[data-font-size="medium"] {
@include font-medium;
}
:root[data-font-size="large"] {
@include font-large;
}
.generic {
@apply text-primary text-primaryLight bg-primary;
}

View File

@@ -0,0 +1,37 @@
// generated by unplugin-vue-components
// We suggest you to commit this file into source control
// Read more: https://github.com/vuejs/core/pull/3399
import '@vue/runtime-core'
export {}
declare module '@vue/runtime-core' {
export interface GlobalComponents {
ButtonPrimary: typeof import('./components/button/Primary.vue')['default']
ButtonSecondary: typeof import('./components/button/Secondary.vue')['default']
IconLucideLoader: typeof import('~icons/lucide/loader')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SmartAnchor: typeof import('./components/smart/Anchor.vue')['default']
SmartAutoComplete: typeof import('./components/smart/AutoComplete.vue')['default']
SmartCheckbox: typeof import('./components/smart/Checkbox.vue')['default']
SmartConfirmModal: typeof import('./components/smart/ConfirmModal.vue')['default']
SmartExpand: typeof import('./components/smart/Expand.vue')['default']
SmartFileChip: typeof import('./components/smart/FileChip.vue')['default']
SmartIntersection: typeof import('./components/smart/Intersection.vue')['default']
SmartItem: typeof import('./components/smart/Item.vue')['default']
SmartLink: typeof import('./components/smart/Link.vue')['default']
SmartModal: typeof import('./components/smart/Modal.vue')['default']
SmartProgressRing: typeof import('./components/smart/ProgressRing.vue')['default']
SmartRadio: typeof import('./components/smart/Radio.vue')['default']
SmartRadioGroup: typeof import('./components/smart/RadioGroup.vue')['default']
SmartSlideOver: typeof import('./components/smart/SlideOver.vue')['default']
SmartSpinner: typeof import('./components/smart/Spinner.vue')['default']
SmartTab: typeof import('./components/smart/Tab.vue')['default']
SmartTabs: typeof import('./components/smart/Tabs.vue')['default']
SmartToggle: typeof import('./components/smart/Toggle.vue')['default']
SmartWindow: typeof import('./components/smart/Window.vue')['default']
SmartWindows: typeof import('./components/smart/Windows.vue')['default']
}
}

View File

@@ -0,0 +1,101 @@
<template>
<SmartLink
:to="to"
:blank="blank"
class="relative inline-flex items-center justify-center py-2 font-bold transition focus:outline-none focus-visible:bg-accentDark"
:class="[
color
? `text-${color}-800 bg-${color}-200 hover:(text-${color}-900 bg-${color}-300) focus-visible:(text-${color}-900 bg-${color}-300)`
: `text-accentContrast bg-accent hover:bg-accentDark focus-visible:bg-accentDark`,
label ? 'px-4' : 'px-2',
rounded ? 'rounded-full' : 'rounded',
{ 'opacity-75 cursor-not-allowed': disabled },
{ 'pointer-events-none': loading },
{ 'px-6 py-4 text-lg': large },
{ 'shadow-lg hover:shadow-xl': shadow },
{
'text-white bg-gradient-to-tr from-gradientFrom via-gradientVia to-gradientTo':
gradient,
},
{
'border border-accent hover:border-accentDark focus-visible:border-accentDark':
outline,
},
]"
:disabled="disabled"
:tabindex="loading ? '-1' : '0'"
role="button"
>
<span
class="inline-flex items-center justify-center whitespace-nowrap"
:class="[{ 'flex-row-reverse': reverse }, { 'opacity-50': loading }]"
>
<component
:is="icon"
v-if="icon"
class="svg-icons"
:class="[
{ '!text-2xl': large },
label ? (reverse ? 'ml-2' : 'mr-2') : '',
]"
/>
{{ label }}
<div v-if="shortcut.length" class="<sm:hidden">
<kbd
v-for="(key, index) in shortcut"
:key="`key-${index}`"
class="shortcut-key !bg-accentDark !border-accentLight"
>
{{ key }}
</kbd>
</div>
</span>
<span
v-if="loading"
class="absolute inset-0 flex items-center justify-center"
>
<SmartSpinner />
</span>
</SmartLink>
</template>
<script setup lang="ts">
import type { Component } from "vue"
interface Props {
to?: string
exact?: boolean
blank?: boolean
label?: string
icon?: object | null | Component // It is a component!
svg?: object | null | Component // It is a component!
color?: string
disabled?: boolean
loading?: boolean
large?: boolean
shadow?: boolean
reverse?: boolean
rounded?: boolean
gradient?: boolean
outline?: boolean
shortcut?: string[]
}
withDefaults(defineProps<Props>(), {
to: "",
exact: true,
blank: false,
label: "",
icon: null,
svg: null,
color: "",
disabled: false,
loading: false,
large: false,
shadow: false,
reverse: false,
rounded: false,
gradient: false,
outline: false,
shortcut: () => [],
})
</script>

View File

@@ -0,0 +1,96 @@
<template>
<SmartLink
:to="to"
:exact="exact"
:blank="blank"
class="inline-flex items-center justify-center py-2 font-semibold transition whitespace-nowrap focus:outline-none"
:class="[
color
? `text-${color}-500 hover:text-${color}-600 focus-visible:text-${color}-600`
: 'text-secondary hover:text-secondaryDark focus-visible:text-secondaryDark',
{ 'pointer-events-none': loading },
label ? 'rounded px-4' : 'px-2',
{ 'rounded-full': rounded },
{ 'opacity-75 cursor-not-allowed': disabled },
{ 'flex-row-reverse': reverse },
{ 'px-6 py-4 text-lg': large },
{
'border border-divider hover:border-dividerDark focus-visible:border-dividerDark':
outline,
},
{
'bg-primaryLight hover:bg-primaryDark focus-visible:bg-primaryDark':
filled,
},
]"
:disabled="disabled"
:tabindex="loading ? '-1' : '0'"
role="button"
>
<span
v-if="!loading"
class="inline-flex items-center justify-center whitespace-nowrap"
:class="{ 'flex-row-reverse': reverse }"
>
<component
:is="icon"
v-if="icon"
class="svg-icons"
:class="[
{ '!text-2xl': large },
label ? (reverse ? 'ml-2' : 'mr-2') : '',
]"
/>
{{ label }}
<div v-if="shortcut.length" class="<sm:hidden">
<kbd
v-for="(key, index) in shortcut"
:key="`key-${index}`"
class="shortcut-key !bg-inherit"
>
{{ key }}
</kbd>
</div>
</span>
<SmartSpinner v-else />
</SmartLink>
</template>
<script setup lang="ts">
import type { Component } from "vue"
interface Props {
to?: string
exact?: boolean
blank?: boolean
label?: string
icon?: object | null | Component // It is a component!
svg?: object | null | Component // It is a component!
color?: string
disabled?: boolean
loading?: boolean
reverse?: boolean
rounded?: boolean
large?: boolean
outline?: boolean
shortcut?: string[]
filled?: boolean
}
withDefaults(defineProps<Props>(), {
to: "",
exact: true,
blank: false,
label: "",
icon: null,
svg: null,
color: "",
disabled: false,
loading: false,
reverse: false,
rounded: false,
large: false,
outline: false,
shortcut: () => [],
filled: false,
})
</script>

View File

@@ -0,0 +1,47 @@
import Primary from "./button/Primary.vue"
import Secondary from "./button/Secondary.vue"
import Anchor from "./smart/Anchor.vue"
import AutoComplete from "./smart/AutoComplete.vue"
import Checkbox from "./smart/Checkbox.vue"
import ConfirmModal from "./smart/ConfirmModal.vue"
import Expand from "./smart/Expand.vue"
import FileChip from "./smart/FileChip.vue"
import Intersection from "./smart/Intersection.vue"
import Item from "./smart/Item.vue"
import Link from "./smart/Link.vue"
import Modal from "./smart/Modal.vue"
import ProgressRing from "./smart/ProgressRing.vue"
import Radio from "./smart/Radio.vue"
import RadioGroup from "./smart/RadioGroup.vue"
import SlideOver from "./smart/SlideOver.vue"
import Spinner from "./smart/Spinner.vue"
import Tab from "./smart/Tab.vue"
import Tabs from "./smart/Tabs.vue"
import Toggle from "./smart/Toggle.vue"
import Window from "./smart/Window.vue"
import Windows from "./smart/Windows.vue"
export default {
Primary,
Secondary,
Anchor,
AutoComplete,
Checkbox,
ConfirmModal,
Expand,
FileChip,
Intersection,
Item,
Link,
Modal,
ProgressRing,
Radio,
RadioGroup,
SlideOver,
Spinner,
Tab,
Tabs,
Toggle,
Window,
Windows,
}

View File

@@ -0,0 +1,70 @@
<template>
<SmartLink
:to="to"
:exact="exact"
:blank="blank"
class="inline-flex items-center justify-center focus:outline-none"
:class="[
color
? `text-${color}-500 hover:text-${color}-600 focus-visible:text-${color}-600`
: 'hover:text-secondaryDark focus-visible:text-secondaryDark',
{ 'opacity-75 cursor-not-allowed': disabled },
{ 'flex-row-reverse': reverse },
]"
:disabled="disabled"
tabindex="0"
>
<component
:is="icon"
v-if="icon"
class="svg-icons"
:class="label ? (reverse ? 'ml-2' : 'mr-2') : ''"
/>
{{ label }}
</SmartLink>
</template>
<script lang="ts">
import { Component, defineComponent, PropType } from "vue"
export default defineComponent({
props: {
to: {
type: String,
default: "",
},
exact: {
type: Boolean,
default: true,
},
blank: {
type: Boolean,
default: false,
},
label: {
type: String,
default: "",
},
icon: {
type: Object as PropType<Component | null>,
default: null,
},
svg: {
type: Object as PropType<Component | null>,
default: null,
},
color: {
type: String,
default: "",
},
disabled: {
type: Boolean,
default: false,
},
reverse: {
type: Boolean,
default: false,
},
},
})
</script>

View File

@@ -0,0 +1,245 @@
<template>
<div class="autocomplete-wrapper">
<input
ref="acInput"
:value="text"
type="text"
autocomplete="off"
:placeholder="placeholder"
:spellcheck="spellcheck"
:autocapitalize="autocapitalize"
:class="styles"
@input.stop="
(e) => {
$emit('input', e.target.value)
updateSuggestions(e)
}
"
@keyup="updateSuggestions"
@click="updateSuggestions"
@keydown="handleKeystroke"
@change="$emit('change', $event)"
/>
<ul
v-if="suggestions.length > 0 && suggestionsVisible"
class="suggestions"
:style="{ transform: `translate(${suggestionsOffsetLeft}px, 0)` }"
>
<li
v-for="(suggestion, index) in suggestions"
:key="`suggestion-${index}`"
:class="{ active: currentSuggestionIndex === index }"
@click.prevent="forceSuggestion(suggestion)"
>
{{ suggestion }}
</li>
</ul>
</div>
</template>
<script>
import { defineComponent } from "vue"
export default defineComponent({
props: {
spellcheck: {
type: Boolean,
default: true,
required: false,
},
autocapitalize: {
type: String,
default: "off",
required: false,
},
placeholder: {
type: String,
default: "",
required: false,
},
source: {
type: Array,
required: true,
},
value: {
type: String,
default: "",
required: false,
},
styles: {
type: String,
default: "",
},
},
emits: ["input", "change"],
data() {
return {
text: this.value,
selectionStart: 0,
suggestionsOffsetLeft: 0,
currentSuggestionIndex: -1,
suggestionsVisible: false,
}
},
computed: {
/**
* Gets the suggestions list to be displayed under the input box.
*
* @returns {default.props.source|{type, required}}
*/
suggestions() {
const input = this.text.substring(0, this.selectionStart)
return (
this.source
.filter(
(entry) =>
entry.toLowerCase().startsWith(input.toLowerCase()) &&
input.toLowerCase() !== entry.toLowerCase()
)
// Cut off the part that's already been typed.
.map((entry) => entry.substring(this.selectionStart))
// We only want the top 10 suggestions.
.slice(0, 10)
)
},
},
watch: {
value(newValue) {
this.text = newValue
},
},
mounted() {
this.updateSuggestions({
target: this.$refs.acInput,
})
},
methods: {
updateSuggestions(event) {
// Hide suggestions if ESC pressed.
if (event.code && event.code === "Escape") {
event.preventDefault()
this.suggestionsVisible = false
this.currentSuggestionIndex = -1
return
}
// As suggestions is a reactive property, this implicitly
// causes suggestions to update.
this.selectionStart = this.$refs.acInput.selectionStart
this.suggestionsOffsetLeft = 12 * this.selectionStart
this.suggestionsVisible = true
},
forceSuggestion(text) {
const input = this.text.substring(0, this.selectionStart)
this.text = input + text
this.selectionStart = this.text.length
this.suggestionsVisible = true
this.currentSuggestionIndex = -1
this.$emit("input", this.text)
},
handleKeystroke(event) {
switch (event.code) {
case "Enter":
event.preventDefault()
if (this.currentSuggestionIndex > -1)
this.forceSuggestion(
this.suggestions.find(
(_item, index) => index === this.currentSuggestionIndex
)
)
break
case "ArrowUp":
event.preventDefault()
this.currentSuggestionIndex =
this.currentSuggestionIndex - 1 >= 0
? this.currentSuggestionIndex - 1
: 0
break
case "ArrowDown":
event.preventDefault()
this.currentSuggestionIndex =
this.currentSuggestionIndex < this.suggestions.length - 1
? this.currentSuggestionIndex + 1
: this.suggestions.length - 1
break
case "Tab": {
const activeSuggestion =
this.suggestions[
this.currentSuggestionIndex >= 0 ? this.currentSuggestionIndex : 0
]
if (!activeSuggestion) {
return
}
event.preventDefault()
const input = this.text.substring(0, this.selectionStart)
this.text = input + activeSuggestion
break
}
}
},
},
})
</script>
<style lang="scss" scoped>
.autocomplete-wrapper {
@apply relative;
@apply contents;
input:focus + ul.suggestions,
ul.suggestions:hover {
@apply block;
}
ul.suggestions {
@apply hidden;
@apply bg-popover;
@apply absolute;
@apply mx-2;
@apply left-0;
@apply z-50;
@apply shadow-lg;
@apply max-h-46;
@apply overflow-y-auto;
top: calc(100% - 4px);
border-radius: 0 0 8px 8px;
li {
@apply w-full;
@apply block;
@apply py-2 px-4;
@apply text-secondary;
&:last-child {
border-radius: 0 0 8px 8px;
}
&:hover,
&.active {
@apply bg-accentDark;
@apply text-accentContrast;
@apply cursor-pointer;
}
}
}
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<div
class="inline-flex items-center justify-center transition cursor-pointer flex-nowrap group hover:text-secondaryDark"
role="checkbox"
:aria-checked="on"
@click="emit('change')"
>
<input
id="checkbox"
type="checkbox"
name="checkbox"
class="checkbox"
:checked="on"
@change="emit('change')"
/>
<label
for="checkbox"
class="pl-0 font-semibold truncate align-middle cursor-pointer"
>
<slot></slot>
</label>
</div>
</template>
<script setup lang="ts">
defineProps({
on: {
type: Boolean,
default: false,
},
})
const emit = defineEmits<{
(e: "change"): void
}>()
</script>
<style lang="scss" scoped>
.checkbox[type="checkbox"] {
@apply appearance-none;
@apply hidden;
& + label {
@apply inline-flex items-center justify-center;
@apply cursor-pointer;
&::before {
@apply border-2 border-divider;
@apply rounded;
@apply group-hover: border-accentDark;
@apply inline-flex;
@apply items-center;
@apply justify-center;
@apply text-transparent;
@apply h-4;
@apply w-4;
@apply font-icon;
@apply mr-2;
@apply transition;
@apply content-["\e876"];
}
}
&:checked + label::before {
@apply bg-accent;
@apply border-accent;
@apply text-accentContrast;
}
}
</style>

View File

@@ -0,0 +1,72 @@
<template>
<SmartModal
v-if="show"
dialog
:title="confirm ?? t?.('modal.confirm') ?? 'Confirm'"
role="dialog"
aria-modal="true"
@close="hideModal"
>
<template #body>
<div class="flex flex-col items-center justify-center">
{{ title }}
</div>
</template>
<template #footer>
<span class="flex space-x-2">
<ButtonPrimary
v-focus
:label="yes ?? t?.('action.yes') ?? 'Yes'"
:loading="!!loadingState"
outline
@click="resolve"
/>
<ButtonSecondary
:label="no ?? t?.('action.no') ?? 'No'"
filled
outline
@click="hideModal"
/>
</span>
</template>
</SmartModal>
</template>
<script setup lang="ts">
import { inject } from "vue"
import { HoppUIPluginOptions, HOPP_UI_OPTIONS } from "./../../index"
const { t } = inject<HoppUIPluginOptions>(HOPP_UI_OPTIONS) ?? {}
const props = withDefaults(
defineProps<{
show: boolean
title?: string | null
confirm?: string | null
yes?: string | null
no?: string | null
loadingState?: boolean | null
}>(),
{
title: null,
confirm: null,
yes: null,
no: null,
loadingState: null,
}
)
const emit = defineEmits<{
(e: "hide-modal"): void
(e: "resolve", title: string | null): void
}>()
const hideModal = () => {
emit("hide-modal")
}
const resolve = () => {
emit("resolve", props.title)
if (props.loadingState === null) emit("hide-modal")
}
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div
class="relative flex flex-col space-y-2 overflow-hidden"
:class="expand ? 'h-full' : 'max-h-32'"
>
<slot name="body"></slot>
<div
class="sticky inset-x-0 bottom-0 flex items-center justify-center flex-shrink-0 overflow-x-auto"
>
<ButtonSecondary
:icon="expand ? IconChevronUp : IconChevronDown"
:label="
expand
? less ?? t?.('action.less') ?? 'Less'
: more ?? t?.('action.more') ?? 'More'
"
filled
rounded
@click="expand = !expand"
/>
</div>
</div>
</template>
<script setup lang="ts">
import IconChevronUp from "~icons/lucide/chevron-up"
import IconChevronDown from "~icons/lucide/chevron-down"
import { inject, ref } from "vue"
import { HoppUIPluginOptions, HOPP_UI_OPTIONS } from "./../../index"
const { t } = inject<HoppUIPluginOptions>(HOPP_UI_OPTIONS) ?? {}
const expand = ref(false)
withDefaults(
defineProps<{
less?: string
more?: string
}>(),
{
less: "Less",
more: "More",
}
)
</script>

View File

@@ -0,0 +1,22 @@
<template>
<span class="chip">
<component :is="IconFile" class="opacity-75 svg-icons" />
<span class="px-2 truncate max-w-32"><slot></slot></span>
</span>
</template>
<script setup lang="ts">
import IconFile from "~icons/lucide/file"
</script>
<style lang="scss" scoped>
.chip {
@apply inline-flex;
@apply items-center;
@apply justify-center;
@apply rounded;
@apply pl-2;
@apply pr-0.5;
@apply bg-primaryDark;
}
</style>

View File

@@ -0,0 +1,38 @@
<template>
<div ref="container">
<slot></slot>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue"
/*
Implements a wrapper listening to viewport intersections via
IntersectionObserver API
Events
------
intersecting (entry: IntersectionObserverEntry) -> When the component is intersecting the viewport
*/
const observer = ref<IntersectionObserver>()
const container = ref<Element>()
const emit = defineEmits<{
(e: "intersecting", entry: IntersectionObserverEntry): void
}>()
onMounted(() => {
observer.value = new IntersectionObserver(([entry]) => {
if (entry && entry.isIntersecting) {
emit("intersecting", entry)
}
})
observer.value.observe(container.value!)
})
onBeforeUnmount(() => {
observer.value!.disconnect()
})
</script>

View File

@@ -0,0 +1,140 @@
<template>
<SmartLink
:to="to"
:exact="exact"
:blank="blank"
class="inline-flex items-center flex-shrink-0 px-4 py-2 rounded transition hover:bg-primaryDark hover:text-secondaryDark focus:outline-none focus-visible:bg-primaryDark focus-visible:text-secondaryDark"
:class="[
{ 'opacity-75 cursor-not-allowed': disabled },
{ 'pointer-events-none': loading },
{ 'flex-1': label },
{ 'flex-row-reverse justify-end': reverse },
{
'border border-divider hover:border-dividerDark focus-visible:border-dividerDark':
outline,
},
]"
:disabled="disabled"
:tabindex="loading ? '-1' : '0'"
role="menuitem"
>
<span
v-if="!loading"
class="inline-flex items-center"
:class="{ 'self-start': !!infoIcon }"
>
<component
:is="icon"
v-if="icon"
class="opacity-75 svg-icons"
:class="[
label ? (reverse ? 'ml-4' : 'mr-4') : '',
{ 'text-accent': active },
]"
/>
</span>
<SmartSpinner v-else class="mr-4 text-secondaryDark" />
<div
class="inline-flex items-start flex-1 truncate"
:class="{ 'flex-col': description }"
>
<div class="font-semibold truncate">
{{ label }}
</div>
<p v-if="description" class="my-2 text-left text-secondaryLight">
{{ description }}
</p>
</div>
<component
:is="infoIcon"
v-if="infoIcon"
class="items-center self-center ml-4 svg-icons"
:class="{ 'text-accent': activeInfoIcon }"
/>
<div v-if="shortcut.length" class="ml-4 <sm:hidden font-medium">
<kbd
v-for="(key, index) in shortcut"
:key="`key-${index}`"
class="-mr-2 shortcut-key"
>
{{ key }}
</kbd>
</div>
</SmartLink>
</template>
<script setup lang="ts">
defineProps({
to: {
type: String,
default: "",
},
exact: {
type: Boolean,
default: true,
},
blank: {
type: Boolean,
default: false,
},
label: {
type: String,
default: "",
},
description: {
type: String,
default: "",
},
/**
* This will be a component!
*/
icon: {
type: Object,
default: null,
},
/**
* This will be a component!
*/
svg: {
type: Object,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
reverse: {
type: Boolean,
default: false,
},
outline: {
type: Boolean,
default: false,
},
shortcut: {
type: Array,
default: () => [],
},
active: {
type: Boolean,
default: false,
},
activeInfoIcon: {
type: Boolean,
default: false,
},
/**
* This will be a component!
*/
infoIcon: {
type: Object,
default: null,
},
})
</script>

View File

@@ -0,0 +1,90 @@
<template>
<button
v-if="renderedTag === 'BUTTON'"
aria-label="button"
role="button"
v-bind="$attrs"
>
<slot></slot>
</button>
<a
v-else-if="renderedTag === 'ANCHOR' && !blank"
aria-label="Link"
:href="to"
role="link"
v-bind="updatedAttrs"
>
<slot></slot>
</a>
<a
v-else-if="renderedTag === 'ANCHOR' && blank"
aria-label="Link"
:href="to"
role="link"
target="_blank"
rel="noopener"
v-bind="updatedAttrs"
>
<slot></slot>
</a>
<RouterLink v-else :to="to" v-bind="updatedAttrs">
<slot></slot>
</RouterLink>
</template>
<script lang="ts">
/**
* for preventing the automatic binding of $attrs.
* we are manually binding $attrs or updatedAttrs.
* if this is not set to false, along with manually binded updatedAttrs, it will also bind $attrs.
*/
export default {
inheritAttrs: false,
}
</script>
<script setup lang="ts">
import { computed, useAttrs } from "vue"
import { RouterLink } from "vue-router"
import { omit } from "lodash-es"
const props = defineProps({
to: {
type: String,
default: "",
},
blank: {
type: Boolean,
default: false,
},
})
const renderedTag = computed(() => {
if (!props.to) {
return "BUTTON" as const
} else if (props.blank) {
return "ANCHOR" as const
} else if (/^\/(?!\/).*$/.test(props.to)) {
// regex101.com/r/LU1iFL/1
return "FRAMEWORK" as const
} else {
return "ANCHOR" as const
}
})
const $attrs = useAttrs()
/**
* tippy checks if the disabled attribute exists on the anchor tag, if it exists it won't show the tooltip.
* and when directly binding the disabled attribute using v-bind="attrs",
* vue renders the disabled attribute as disabled="false" ("false" being a string),
* which causes tippy to think the disabled attribute is present, ( it does a targetElement.hasAttribute("disabled") check ) and it won't show the tooltip.
*
* here we are just omiting disabled if it is false.
*/
const updatedAttrs = computed(() =>
renderedTag.value === "ANCHOR" && !$attrs.disabled
? omit($attrs, "disabled")
: $attrs
)
</script>

View File

@@ -0,0 +1,219 @@
<template>
<Transition name="fade" appear @leave="onTransitionLeaveStart">
<div
ref="modal"
class="fixed inset-0 z-50 overflow-y-auto transition"
role="dialog"
>
<div
class="flex items-end justify-center min-h-screen text-center sm:block"
>
<Transition name="fade" appear>
<div
class="fixed inset-0 transition-opacity"
@touchstart="!dialog ? close() : null"
@touchend="!dialog ? close() : null"
@mouseup="!dialog ? close() : null"
@mousedown="!dialog ? close() : null"
>
<div
class="absolute inset-0 opacity-80 bg-primaryLight focus:outline-none"
tabindex="0"
@click="!dialog ? close() : null"
></div>
</div>
</Transition>
<span
v-if="placement === 'center'"
class="sm:h-screen <sm:hidden sm:align-middle"
aria-hidden="true"
>&#8203;</span
>
<Transition name="bounce" appear>
<div
class="inline-block w-full overflow-hidden text-left align-bottom transition-all transform shadow-lg sm:border border-dividerDark bg-primary sm:rounded-xl sm:align-middle"
:class="[{ 'mt-24 md:mb-8': placement === 'top' }, styles]"
>
<div
v-if="title"
class="flex items-center justify-between border-b border-dividerLight"
:class="{ 'p-4': !fullWidth }"
>
<h3 class="heading" :class="{ 'ml-4': !fullWidth }">
{{ title }}
</h3>
<span class="flex items-center">
<slot name="actions"></slot>
<kbd class="mr-2 shortcut-key">ESC</kbd>
<ButtonSecondary
v-if="dimissible"
v-tippy="{ theme: 'tooltip', delay: [500, 20] }"
:title="closeText ?? t?.('action.close') ?? 'Close'"
:icon="IconX"
@click="close"
/>
</span>
</div>
<div
class="flex flex-col overflow-y-auto max-h-lg"
:class="{ 'p-4': !fullWidth }"
>
<slot name="body"></slot>
</div>
<div
v-if="hasFooterSlot"
class="flex items-center justify-between flex-1 border-t border-dividerLight bg-primaryContrast"
:class="{ 'p-4': !fullWidth }"
>
<slot name="footer"></slot>
</div>
</div>
</Transition>
</div>
</div>
</Transition>
</template>
<script lang="ts">
const PORTAL_DOM_ID = "hoppscotch-modal-portal"
// An ID ticker for generating consistently unique modal IDs
let stackIDTicker = 0
// Why ?
const stack = (() => {
const stack: number[] = []
return {
push: stack.push.bind(stack),
pop: stack.pop.bind(stack),
peek: () => (stack.length === 0 ? undefined : stack[stack.length - 1]),
}
})()
</script>
<script setup lang="ts">
import IconX from "~icons/lucide/x"
import {
ref,
computed,
useSlots,
onMounted,
onBeforeUnmount,
inject,
} from "vue"
import { HoppUIPluginOptions, HOPP_UI_OPTIONS } from "./../../index"
const { t, onModalOpen, onModalClose } =
inject<HoppUIPluginOptions>(HOPP_UI_OPTIONS) ?? {}
defineProps({
dialog: {
type: Boolean,
default: false,
},
title: {
type: String,
default: "",
},
dimissible: {
type: Boolean,
default: true,
},
placement: {
type: String,
default: "top",
},
fullWidth: {
type: Boolean,
default: false,
},
styles: {
type: String,
default: "sm:max-w-lg",
},
closeText: {
type: String,
default: null,
},
})
const emit = defineEmits<{
(e: "close"): void
}>()
onBeforeUnmount(() => {
onModalClose?.()
})
const stackId = ref(stackIDTicker++)
const shouldCleanupDomOnUnmount = ref(true)
const slots = useSlots()
const hasFooterSlot = computed(() => {
return !!slots.footer
})
const modal = ref<Element>()
onMounted(() => {
const portal = getPortal()
portal.appendChild(modal.value!)
stack.push(stackId.value)
document.addEventListener("keydown", onKeyDown)
onModalOpen?.()
})
onBeforeUnmount(() => {
if (shouldCleanupDomOnUnmount.value && modal.value) {
getPortal().removeChild(modal.value)
}
stack.pop()
document.removeEventListener("keydown", onKeyDown)
})
const close = () => {
emit("close")
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && stackId.value === stack.peek()) {
e.preventDefault()
close()
}
}
const onTransitionLeaveStart = () => {
close()
shouldCleanupDomOnUnmount.value = false
}
const getPortal = () => {
let el = document.querySelector("#" + PORTAL_DOM_ID)
if (el) {
return el
}
el = document.createElement("DIV")
el.id = PORTAL_DOM_ID
document.body.appendChild(el)
return el
}
</script>
<style lang="scss" scoped>
.bounce-enter-active {
@apply transition;
animation: bounce-in 150ms;
}
@keyframes bounce-in {
0% {
transform: scale(0.95);
}
100% {
transform: scale(1);
}
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<svg :height="radius * 2" :width="radius * 2">
<circle
:stroke-width="stroke"
class="stroke-green-500"
fill="transparent"
:r="normalizedRadius"
:cx="radius"
:cy="radius"
/>
<circle
:stroke-width="stroke"
stroke="currentColor"
fill="transparent"
:r="normalizedRadius"
:cx="radius"
:cy="radius"
:style="{ strokeDashoffset: strokeDashoffset }"
:stroke-dasharray="circumference + ' ' + circumference"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue"
export default defineComponent({
props: {
radius: {
type: Number,
default: 12,
},
progress: {
type: Number,
default: 50,
},
stroke: {
type: Number,
default: 4,
},
},
data() {
const normalizedRadius = this.radius - this.stroke * 2
const circumference = normalizedRadius * 2 * Math.PI
return {
normalizedRadius,
circumference,
}
},
computed: {
strokeDashoffset() {
return this.circumference - (this.progress / 100) * this.circumference
},
},
})
</script>

View File

@@ -0,0 +1,34 @@
<template>
<SmartItem
:label="label"
:icon="selected ? IconCircleDot : IconCircle"
:active="selected"
role="radio"
:aria-checked="selected"
@click="emit('change', value)"
/>
</template>
<script setup lang="ts">
import IconCircleDot from "~icons/lucide/circle-dot"
import IconCircle from "~icons/lucide/circle"
defineProps({
value: {
type: String,
default: "",
},
label: {
type: String,
default: "",
},
selected: {
type: Boolean,
default: false,
},
})
const emit = defineEmits<{
(e: "change", value: string): void
}>()
</script>

View File

@@ -0,0 +1,26 @@
<template>
<div class="flex flex-col">
<SmartRadio
v-for="(radio, index) in radios"
:key="`radio-${index}`"
:value="radio.value"
:label="radio.label"
:selected="modelValue === radio.value"
@change="emit('update:modelValue', radio.value)"
/>
</div>
</template>
<script setup lang="ts">
const emit = defineEmits<{
(e: "update:modelValue", value: string): void
}>()
defineProps<{
radios: Array<{
value: string // The key of the radio option
label: string
}>
modelValue: string // Should be a radio key given in the radios array
}>()
</script>

View File

@@ -0,0 +1,66 @@
<template>
<div>
<Transition name="fade" appear>
<div
v-if="show"
class="fixed inset-0 z-20 transition-opacity"
@keydown.esc="close()"
>
<div
class="absolute inset-0 bg-primaryLight opacity-90 focus:outline-none"
tabindex="0"
@click="close()"
></div>
</div>
</Transition>
<Transition name="slide" appear>
<aside
v-if="show"
class="fixed top-0 right-0 z-30 flex flex-col h-full max-w-full overflow-auto border-l shadow-xl border-dividerDark bg-primary w-96"
>
<div
class="flex items-center justify-between p-2 border-b border-dividerLight"
>
<h3 class="ml-4 heading">{{ title }}</h3>
<span class="flex items-center">
<kbd class="mr-2 shortcut-key">ESC</kbd>
<ButtonSecondary :icon="IconX" @click="close()" />
</span>
</div>
<slot name="content"></slot>
</aside>
</Transition>
</div>
</template>
<script setup lang="ts">
import { onMounted, watch } from "vue"
import IconX from "~icons/lucide/x"
const props = defineProps<{
show: boolean
title: string
}>()
const emit = defineEmits<{
(e: "close"): void
}>()
watch(
() => props.show,
(show) => {
if (show) document.body.style.setProperty("overflow", "hidden")
else document.body.style.removeProperty("overflow")
}
)
onMounted(() => {
document.addEventListener("keydown", (e) => {
if (e.keyCode === 27 && props.show) close()
})
})
const close = () => {
emit("close")
}
</script>

View File

@@ -0,0 +1,3 @@
<template>
<icon-lucide-loader class="animate-spin svg-icons" />
</template>

View File

@@ -0,0 +1,78 @@
<template>
<div v-if="shouldRender" v-show="active" class="flex flex-col flex-1">
<slot></slot>
</div>
</template>
<script setup lang="ts">
import {
onMounted,
onBeforeUnmount,
inject,
computed,
watch,
Component,
markRaw,
} from "vue"
import { TabMeta, TabProvider } from "./Tabs.vue"
const props = withDefaults(
defineProps<{
id: string
label: string
icon?: Component | object | string | null
info?: string | null
indicator?: boolean
disabled?: boolean
}>(),
{
icon: null,
indicator: false,
info: null,
disabled: false,
}
)
const tabMeta = computed<TabMeta>(() => ({
// props.icon can store a component, which should not be made deeply reactive
icon:
props.icon && typeof props.icon === "object"
? markRaw(props.icon)
: props.icon,
indicator: props.indicator,
info: props.info,
label: props.label,
disabled: props.disabled,
}))
const {
activeTabID,
renderInactive,
addTabEntry,
updateTabEntry,
removeTabEntry,
} = inject<TabProvider>("tabs-system")!
const active = computed(() => activeTabID.value === props.id)
const shouldRender = computed(() => {
// If render inactive is true, then it should be rendered nonetheless
if (renderInactive.value) return true
// Else, return whatever is the active state
return active.value
})
onMounted(() => {
addTabEntry(props.id, tabMeta.value)
})
watch(tabMeta, (newMeta) => {
updateTabEntry(props.id, newMeta)
})
onBeforeUnmount(() => {
removeTabEntry(props.id)
})
</script>

View File

@@ -0,0 +1,251 @@
<template>
<div
class="flex flex-1 h-full flex-nowrap"
:class="{ 'flex-col h-auto': !vertical }"
>
<div
class="relative tabs border-dividerLight"
:class="[vertical ? 'border-r' : 'border-b', styles]"
>
<div class="flex flex-1">
<div
class="flex justify-between flex-1"
:class="{ 'flex-col': vertical }"
>
<div class="flex" :class="{ 'flex-col space-y-2 p-2': vertical }">
<button
v-for="([tabID, tabMeta], index) in tabEntries"
:key="`tab-${index}`"
v-tippy="{
theme: 'tooltip',
placement: 'left',
content: vertical ? tabMeta.label : null,
}"
class="tab"
:class="[
{ active: modelValue === tabID },
{ vertical: vertical },
{ 'opacity-75 !cursor-not-allowed': tabMeta.disabled },
]"
:aria-label="tabMeta.label || ''"
:disabled="tabMeta.disabled"
role="button"
@keyup.enter="selectTab(tabID)"
@click="selectTab(tabID)"
>
<component
:is="tabMeta.icon"
v-if="tabMeta.icon"
class="svg-icons"
/>
<span v-else-if="tabMeta.label">{{ tabMeta.label }}</span>
<span
v-if="tabMeta.info && tabMeta.info !== 'null'"
class="tab-info"
>
{{ tabMeta.info }}
</span>
<span
v-if="tabMeta.indicator"
class="w-1 h-1 ml-2 rounded-full bg-accentLight"
></span>
</button>
</div>
<div class="flex items-center justify-center">
<slot name="actions"></slot>
</div>
</div>
</div>
</div>
<div
class="w-full h-full contents"
:class="[
{
'!flex flex-col flex-1 overflow-y-auto': vertical,
},
contentStyles,
]"
>
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { pipe } from "fp-ts/function"
import { not } from "fp-ts/Predicate"
import * as A from "fp-ts/Array"
import * as O from "fp-ts/Option"
import type { Component } from "vue"
import { ref, ComputedRef, computed, provide } from "vue"
export type TabMeta = {
label: string | null
icon: string | Component | null
indicator: boolean
info: string | null
disabled: boolean
}
export type TabProvider = {
// Whether inactive tabs should remain rendered
renderInactive: ComputedRef<boolean>
activeTabID: ComputedRef<string>
addTabEntry: (tabID: string, meta: TabMeta) => void
updateTabEntry: (tabID: string, newMeta: TabMeta) => void
removeTabEntry: (tabID: string) => void
}
const props = defineProps({
styles: {
type: String,
default: "",
},
renderInactiveTabs: {
type: Boolean,
default: false,
},
vertical: {
type: Boolean,
default: false,
},
modelValue: {
type: String,
required: true,
},
contentStyles: {
type: String,
default: "",
},
})
const emit = defineEmits<{
(e: "update:modelValue", newTabID: string): void
}>()
const throwError = (message: string): never => {
throw new Error(message)
}
const tabEntries = ref<Array<[string, TabMeta]>>([])
const addTabEntry = (tabID: string, meta: TabMeta) => {
tabEntries.value = pipe(
tabEntries.value,
O.fromPredicate(not(A.exists(([id]) => id === tabID))),
O.map(A.append([tabID, meta] as [string, TabMeta])),
O.getOrElseW(() => throwError(`Tab with duplicate ID created: '${tabID}'`))
)
}
const updateTabEntry = (tabID: string, newMeta: TabMeta) => {
tabEntries.value = pipe(
tabEntries.value,
A.findIndex(([id]) => id === tabID),
O.chain((index) =>
pipe(
tabEntries.value,
A.updateAt(index, [tabID, newMeta] as [string, TabMeta])
)
),
O.getOrElseW(() => throwError(`Failed to update tab entry: ${tabID}`))
)
}
const removeTabEntry = (tabID: string) => {
tabEntries.value = pipe(
tabEntries.value,
A.findIndex(([id]) => id === tabID),
O.chain((index) => pipe(tabEntries.value, A.deleteAt(index))),
O.getOrElseW(() => throwError(`Failed to remove tab entry: ${tabID}`))
)
// If we tried to remove the active tabEntries, switch to first tab entry
if (props.modelValue === tabID)
if (tabEntries.value.length > 0) selectTab(tabEntries.value[0][0])
}
provide<TabProvider>("tabs-system", {
renderInactive: computed(() => props.renderInactiveTabs),
activeTabID: computed(() => props.modelValue),
addTabEntry,
updateTabEntry,
removeTabEntry,
})
const selectTab = (id: string) => {
emit("update:modelValue", id)
}
</script>
<style lang="scss" scoped>
.tabs {
@apply flex;
@apply whitespace-nowrap;
@apply overflow-auto;
@apply flex-shrink-0;
.tab {
@apply relative;
@apply flex;
@apply flex-shrink-0;
@apply items-center;
@apply justify-center;
@apply py-2 px-4;
@apply text-secondary;
@apply font-semibold;
@apply cursor-pointer;
@apply hover: text-secondaryDark;
@apply focus: outline-none;
@apply focus-visible: text-secondaryDark;
@apply after:absolute;
@apply after:left-4;
@apply after:right-4;
@apply after:bottom-0;
@apply after:bg-transparent;
@apply after:z-2;
@apply after:h-0.5;
@apply after:content-DEFAULT;
@apply focus: after: bg-divider;
.tab-info {
@apply inline-flex;
@apply items-center;
@apply justify-center;
@apply w-5;
@apply h-4;
@apply ml-2;
@apply text-8px;
@apply border border-divider;
@apply rounded;
@apply text-secondaryLight;
}
&.active {
@apply text-secondaryDark;
@apply after:bg-accent;
.tab-info {
@apply text-secondary;
@apply border-dividerDark;
}
}
&.vertical {
@apply p-2;
@apply rounded;
@apply focus: after: hidden;
&.active {
@apply text-accent;
@apply after:hidden;
.tab-info {
@apply text-secondary;
@apply border-dividerDark;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,89 @@
<template>
<div
class="inline-flex items-center justify-center cursor-pointer transition flex-nowrap group hover:text-secondaryDark rounded py-0.5 px-1 -my-0.5 -mx-1 focus:outline-none focus-visible:ring focus-visible:ring-accent focus-visible:text-secondaryDark"
tabindex="0"
@click="emit('change')"
@keyup.enter="emit('change')"
>
<span ref="toggle" class="toggle" :class="{ on: on }">
<span class="handle"></span>
</span>
<span class="pl-0 font-semibold truncate align-middle cursor-pointer">
<slot></slot>
</span>
</div>
</template>
<script setup lang="ts">
defineProps({
on: {
type: Boolean,
default: false,
},
})
const emit = defineEmits<{
(e: "change"): void
}>()
</script>
<style lang="scss" scoped>
$useBorder: true;
$borderColor: var(--divider-color);
$activeColor: var(--divider-dark-color);
$inactiveColor: var(--divider-color);
$inactiveHandleColor: var(--secondary-light-color);
$activeHandleColor: var(--accent-color);
$width: 1.6rem;
$height: 0.6rem;
$indicatorHeight: 0.4rem;
$indicatorWidth: 0.4rem;
$handleSpacing: 0.1rem;
$transition: all 0.2s ease-in-out;
.toggle {
@apply relative;
@apply flex;
@apply items-center;
@apply justify-center;
@apply rounded-full;
@apply p-0;
@apply mr-4;
@apply cursor-pointer;
@apply flex-shrink-0;
@apply transition;
@apply group-hover: border-accentDark;
@apply focus: outline-none;
@apply focus-visible: border-accentDark;
width: $width;
height: $height;
border: if($useBorder, 2px solid $borderColor, none);
background-color: if($useBorder, transparent, $inactiveColor);
box-sizing: initial;
.handle {
@apply absolute;
@apply flex;
@apply flex-shrink-0;
@apply inset-0;
@apply rounded-full;
@apply pointer-events-none;
transition: $transition;
margin: $handleSpacing;
background-color: $inactiveHandleColor;
width: $indicatorWidth;
height: $indicatorHeight;
}
&.on {
// background-color: $activeColor;
border-color: $activeColor;
@apply focus-visible: border-accentDark;
.handle {
background-color: $activeHandleColor;
left: #{$width - $height};
}
}
}
</style>

View File

@@ -0,0 +1,49 @@
<template>
<div v-show="active" class="flex flex-col flex-1 overflow-y-auto">
<slot></slot>
</div>
</template>
<script setup lang="ts">
import {
onMounted,
onBeforeUnmount,
inject,
computed,
watch,
useSlots,
} from "vue"
import { TabMeta, TabProvider } from "./Windows.vue"
const slots = useSlots()
const props = defineProps({
label: { type: String, default: null },
info: { type: String, default: null },
id: { type: String, default: null, required: true },
isRemovable: { type: Boolean, default: true },
selected: {
type: Boolean,
default: false,
},
})
const tabMeta = computed<TabMeta>(() => ({
info: props.info,
label: props.label,
isRemovable: props.isRemovable,
icon: slots.icon,
}))
const { activeTabID, addTabEntry, updateTabEntry, removeTabEntry } =
inject<TabProvider>("tabs-system")!
const active = computed(() => activeTabID.value === props.id)
onMounted(() => {
addTabEntry(props.id, tabMeta.value)
})
watch(tabMeta, (newMeta) => {
updateTabEntry(props.id, newMeta)
})
onBeforeUnmount(() => {
removeTabEntry(props.id)
})
</script>

View File

@@ -0,0 +1,263 @@
<template>
<div class="flex flex-col flex-1 h-auto overflow-y-hidden flex-nowrap">
<div
class="relative sticky top-0 z-10 flex-shrink-0 overflow-x-auto tabs bg-primaryLight"
>
<div class="flex flex-1 flex-shrink-0 w-0 overflow-x-auto">
<div class="flex justify-between divide-x divide-dividerLight">
<div class="flex">
<draggable
v-bind="dragOptions"
:list="tabEntries"
:style="tabStyles"
:item-key="'window-'"
class="flex flex-shrink-0 overflow-x-auto transition divide-x divide-dividerLight"
@sort="sortTabs"
>
<template #item="{ element: [tabID, tabMeta] }">
<button
:key="`removable-tab-${tabID}`"
class="tab"
:class="[{ active: modelValue === tabID }]"
:aria-label="tabMeta.label || ''"
role="button"
@keyup.enter="selectTab(tabID)"
@click="selectTab(tabID)"
>
<div class="flex items-stretch truncate">
<span
v-if="tabMeta.icon"
class="flex items-center justify-center mx-4 cursor-pointer"
>
<component :is="tabMeta.icon" class="w-4 h-4 svg-icons" />
</span>
<span class="truncate">
{{ tabMeta.label }}
</span>
</div>
<ButtonSecondary
v-tippy="{ theme: 'tooltip', delay: [500, 20] }"
:icon="IconX"
:style="{
visibility: tabMeta.isRemovable ? 'visible' : 'hidden',
}"
:title="closeText ?? t?.('action.close') ?? 'Close'"
:class="[{ active: modelValue === tabID }, 'close']"
class="mx-2 !p-0.5"
@click.stop="emit('removeTab', tabID)"
/>
</button>
</template>
</draggable>
</div>
<div
class="sticky right-0 flex items-center justify-center flex-shrink-0 overflow-x-auto z-8"
>
<slot name="actions">
<span
v-if="canAddNewTab"
class="flex items-center justify-center px-2 py-1.5 bg-primaryLight z-8"
>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="newText ?? t?.('action.new') ?? 'New'"
:icon="IconPlus"
class="rounded !p-1"
filled
@click="addTab"
/>
</span>
</slot>
</div>
</div>
</div>
</div>
<div class="w-full h-full contents">
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
import IconPlus from "~icons/lucide/plus"
import IconX from "~icons/lucide/x"
import { pipe } from "fp-ts/function"
import { not } from "fp-ts/Predicate"
import * as A from "fp-ts/Array"
import * as O from "fp-ts/Option"
import { ref, ComputedRef, computed, provide, inject } from "vue"
import type { Slot } from "vue"
import draggable from "vuedraggable"
import { HoppUIPluginOptions, HOPP_UI_OPTIONS } from "./../../index"
export type TabMeta = {
label: string | null
icon: Slot | undefined
info: string | null
isRemovable: boolean
}
export type TabProvider = {
activeTabID: ComputedRef<string>
addTabEntry: (tabID: string, meta: TabMeta) => void
updateTabEntry: (tabID: string, newMeta: TabMeta) => void
removeTabEntry: (tabID: string) => void
}
const { t } = inject<HoppUIPluginOptions>(HOPP_UI_OPTIONS) ?? {}
const props = defineProps({
styles: {
type: String,
default: "",
},
modelValue: {
type: String,
required: true,
},
canAddNewTab: {
type: Boolean,
default: true,
},
newText: {
type: String,
default: null,
},
closeText: {
type: String,
default: null,
},
})
const emit = defineEmits<{
(e: "update:modelValue", newTabID: string): void
(e: "sort", body: { oldIndex: number; newIndex: number }): void
(e: "removeTab", tabID: string): void
(e: "addTab"): void
}>()
const throwError = (message: string): never => {
throw new Error(message)
}
const tabEntries = ref<Array<[string, TabMeta]>>([])
const tabStyles = computed(() => ({
maxWidth: `${tabEntries.value.length * 184}px`,
width: "100%",
minWidth: "0px",
// transition: "max-width 0.2s",
}))
const dragOptions = {
group: "tabs",
animation: 250,
handle: ".tab",
draggable: ".tab",
ghostClass: "cursor-move",
}
const addTabEntry = (tabID: string, meta: TabMeta) => {
tabEntries.value = pipe(
tabEntries.value,
O.fromPredicate(not(A.exists(([id]) => id === tabID))),
O.map(A.append([tabID, meta] as [string, TabMeta])),
O.getOrElseW(() => throwError(`Tab with duplicate ID created: '${tabID}'`))
)
}
const updateTabEntry = (tabID: string, newMeta: TabMeta) => {
tabEntries.value = pipe(
tabEntries.value,
A.findIndex(([id]) => id === tabID),
O.chain((index) =>
pipe(
tabEntries.value,
A.updateAt(index, [tabID, newMeta] as [string, TabMeta])
)
),
O.getOrElseW(() => throwError(`Failed to update tab entry: ${tabID}`))
)
}
const removeTabEntry = (tabID: string) => {
tabEntries.value = pipe(
tabEntries.value,
A.findIndex(([id]) => id === tabID),
O.chain((index) => pipe(tabEntries.value, A.deleteAt(index))),
O.getOrElseW(() => throwError(`Failed to remove tab entry: ${tabID}`))
)
// If we tried to remove the active tabEntries, switch to first tab entry
if (props.modelValue === tabID)
if (tabEntries.value.length > 0) selectTab(tabEntries.value[0][0])
}
const sortTabs = (e: {
oldDraggableIndex: number
newDraggableIndex: number
}) => {
emit("sort", {
oldIndex: e.oldDraggableIndex,
newIndex: e.newDraggableIndex,
})
}
provide<TabProvider>("tabs-system", {
activeTabID: computed(() => props.modelValue),
addTabEntry,
updateTabEntry,
removeTabEntry,
})
const selectTab = (id: string) => {
emit("update:modelValue", id)
}
const addTab = () => {
emit("addTab")
}
</script>
<style scoped lang="scss">
.tabs {
@apply flex;
@apply whitespace-nowrap;
@apply overflow-auto;
@apply flex-shrink-0;
@apply after:absolute;
@apply after:inset-x-0;
@apply after:bottom-0;
@apply after:bg-dividerLight;
@apply after:z-10;
@apply after:h-0.25;
@apply after:content-DEFAULT;
.tab {
@apply relative;
@apply flex;
@apply py-2;
@apply font-semibold;
@apply w-46;
@apply transition;
@apply flex-1;
@apply items-center;
@apply justify-between;
@apply text-secondaryLight;
@apply hover:bg-primaryDark;
@apply hover:text-secondary;
@apply focus-visible:text-secondaryDark;
@apply before:absolute;
@apply before:left-0;
@apply before:right-0;
@apply before:top-0;
@apply before:bg-transparent;
@apply before:z-2;
@apply before:h-0.5;
@apply before:content-DEFAULT;
@apply focus: before: bg-divider;
&.active {
@apply text-secondaryDark;
@apply bg-primary;
@apply before:bg-accent;
}
.close {
@apply opacity-50;
&.active {
@apply opacity-80;
}
}
}
}
</style>

8
packages/hoppscotch-ui/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import { DefineComponent } from "vue"
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@@ -0,0 +1,33 @@
import { Plugin } from "vue"
import "./assets/scss/styles.scss"
import "virtual:windi.css"
/**
@constant HOPP_UI_OPTIONS
@type {string}
A constant representing the key for storing HoppUI plugin options in the global context.
*/
export const HOPP_UI_OPTIONS = "HOPP_UI_OPTIONS"
/**
@typedef {Object} HoppUIPluginOptions
@property {Function} [t] - A function for handling translations for the plugin.
@property {Function} [onModalOpen] - A callback function that is called when a modal is opened.
@property {Function} [onModalClose] - A callback function that is called when a modal is closed.
*/
export type HoppUIPluginOptions = {
t?: (key: string) => string
onModalOpen?: () => void
onModalClose?: () => void
}
const plugin: Plugin = {
install(app, options: HoppUIPluginOptions = {}) {
app.provide(HOPP_UI_OPTIONS, options)
},
}
export default plugin

View File

@@ -0,0 +1,20 @@
<template>
<Story title="Anchor">
<div class="text-secondaryLight text-tiny">
By signing in, you are agreeing to our
<SmartAnchor
class="link text-red-800"
to="https://docs.hoppscotch.io/terms"
blank
label="Terms of Service"
/>
and
<SmartAnchor
class="link text-red-600"
to="https://docs.hoppscotch.io/privacy"
blank
label="Privacy Policy"
/>
</div>
</Story>
</template>

View File

@@ -0,0 +1,55 @@
<template>
<Story title="Auto Complete">
<div class="h-[50vh]">
<SmartAutoComplete
placeholder="Select a header"
:source="commonHeaders"
:spellcheck="false"
:value="header[0].key"
autofocus
styles="
bg-transparent
flex
flex-1
py-1
px-4
truncate
"
class="flex-1 !flex"
@input="updateHeader()"
/>
</div>
</Story>
</template>
<script setup lang="ts">
import { ref } from "vue"
type GQLHeader = {
key: string
value: string
active: boolean
}
const header = ref<GQLHeader[]>([
{
key: "Content-Type",
value: "application/json",
active: true,
},
])
const commonHeaders = [
"Push-Policy",
"Retry-After",
"Signature",
"Signed-Headers",
"Server-Timing",
"SourceMap",
"Upgrade",
]
const updateHeader = () => {
// alert("updated")
}
</script>

View File

@@ -0,0 +1,10 @@
<template>
<Story title="Button">
<Variant title="Primary">
<ButtonPrimary label="Button" />
</Variant>
<Variant title="Secondary">
<ButtonSecondary label="Button" />
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,13 @@
<template>
<Story title="Checkbox">
<Variant title="Single">
<SmartCheckbox :on="on" />
</Variant>
</Story>
</template>
<script setup lang="ts">
import { ref } from "vue"
const on = ref(true)
</script>

View File

@@ -0,0 +1,21 @@
<template>
<Story title="Confirm Modal">
<SmartConfirmModal
:show="show"
:title="'Confirm Modal'"
@hide-modal="show = false"
@resolve="resolveConfirmModal"
/>
</Story>
</template>
<script lang="ts" setup>
import { ref } from "vue"
const show = ref(true)
const resolveConfirmModal = (resolve: string | null) => {
alert("resolved: " + resolve)
show.value = false
}
</script>

View File

@@ -0,0 +1,7 @@
<template>
<Story title="Item">
<Variant title="Single">
<SmartItem :label="'Item'" :active-info-icon="false" />
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,18 @@
<template>
<Story title="Link">
<Variant title="Text Link">
<SmartLink :to="link" :blank="true"> Click here </SmartLink>
</Variant>
<Variant title="Button Link">
<SmartLink :to="link" :blank="true">
<ButtonPrimary label="Click here" />
</SmartLink>
</Variant>
</Story>
</template>
<script setup lang="ts">
import { ref } from "vue"
const link = ref("/graphql")
</script>

View File

@@ -0,0 +1,21 @@
<template>
<Story title="Modal">
<SmartModal
:show="show"
:title="'Modal Title'"
@hide-modal="show = false"
@resolve="resolveConfirmModal"
/>
</Story>
</template>
<script lang="ts" setup>
import { ref } from "vue"
const show = ref(true)
const resolveConfirmModal = (resolve: string | null) => {
alert("resolved: " + resolve)
show.value = false
}
</script>

View File

@@ -0,0 +1,15 @@
<template>
<Story title="Progress Ring">
<SmartProgressRing
class="mr-2 text-red-500"
:radius="8"
:stroke="1.5"
:progress="(failedTests / totalTests) * 100"
/>
</Story>
</template>
<script setup lang="ts">
const totalTests = 10
const failedTests = 2
</script>

View File

@@ -0,0 +1,21 @@
<template>
<Story title="Radio">
<Variant title="Single">
<SmartRadio />
</Variant>
<Variant title="Group">
<SmartRadioGroup :radios="radios" :model-value="selected" />
</Variant>
</Story>
</template>
<script setup lang="ts">
import { ref } from "vue"
const selected = ref("option1")
const radios = [
{ label: "Option 1", value: "option1" },
{ label: "Option 2", value: "option2" },
{ label: "Option 3", value: "option3" },
]
</script>

View File

@@ -0,0 +1,15 @@
<template>
<Story title="Slider Over">
<SmartSlideOver :show="show" :title="'Title'" @close="show = false">
<template #content>
<h1>Content</h1>
</template>
</SmartSlideOver>
</Story>
</template>
<script lang="ts" setup>
import { ref } from "vue"
const show = ref(true)
</script>

View File

@@ -0,0 +1,5 @@
<template>
<Story title="Spinner">
<SmartSpinner />
</Story>
</template>

View File

@@ -0,0 +1,20 @@
<template>
<Story title="Tab">
<Variant title="Single">
<SmartTabs id="my-tab" v-model="selectedTab" render-inactive-tabs>
<SmartTab id="tab1" label="Tab 1">
<h1>Tab 1 content</h1>
</SmartTab>
<SmartTab id="tab2" label="Tab 2">
<h1>Tab 2 content</h1>
</SmartTab>
</SmartTabs>
</Variant>
</Story>
</template>
<script setup lang="ts">
import { ref } from "vue"
const selectedTab = ref("tab1")
</script>

View File

@@ -0,0 +1,15 @@
<template>
<Story title="Toggle">
<SmartToggle :on="on" @change="change"> Turn on </SmartToggle>
</Story>
</template>
<script setup lang="ts">
import { ref } from "vue"
const on = ref(true)
const change = () => {
alert("changed to: " + on.value)
}
</script>

View File

@@ -0,0 +1,72 @@
<template>
<Story title="Window">
<Variant title="Single">
<SmartWindows
:id="'my-window'"
v-model="selectedWindow"
@add-tab="openNewTab"
@remove-tab="removeTab"
@sort="sortTabs"
>
<SmartWindow
v-for="window in tabs"
:id="window.id"
:key="'tab_' + window.id"
:label="window.name"
:is-removable="window.removable"
>
</SmartWindow>
</SmartWindows>
</Variant>
</Story>
</template>
<script setup lang="ts">
import { ref } from "vue"
const selectedWindow = ref("window1")
type Tab = {
id: string
name: string
removable: boolean
}
const tabs = ref<Tab[]>([
{
id: "1",
name: "window1",
removable: false,
},
{
id: "2",
name: "window2",
removable: true,
},
{
id: "3",
name: "window3",
removable: true,
},
])
const openNewTab = () => {
const newTab = {
id: Date.now().toString(),
name: "New Window",
removable: true,
}
tabs.value = [...tabs.value, { ...newTab }]
selectedWindow.value = newTab.id
}
const removeTab = (tabID: string) => {
tabs.value = tabs.value.filter((tab) => tab.id !== tabID)
}
const sortTabs = (e: { oldIndex: number; newIndex: number }) => {
const newTabs = [...tabs.value]
newTabs.splice(e.newIndex, 0, newTabs.splice(e.oldIndex, 1)[0])
tabs.value = newTabs
}
</script>