【Vue 3】心理学を利用したユーザビリティ・コンポーネントをつくる

こんにちは。フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、おそらく開発者ならみなさんそうかもしれませんが「せっかく作ったら使ってほしい」という気持ちがあるんじゃないでしょうか。

そのためにはやはり「ユーザビリティ」が重要で、私は最近こう思うようになりました。

「ユーザビリティのほぼ全ては優しさで出来ている」

と(お薬のキャッチコピーみたいですが😂)

やはり、人の気持ちを予測して、その人が欲しがっているものを提供するというのは「優しさ」そのものだと思うわけです。(これって、やりすぎても足りなくてもダメですよね)

そして、これを実現するために「心理学的なアプローチ」が必要じゃないかとかねがね思っていたのですが、そういった学術的な情報は難しすぎるため、どっかに、

「心理学 + プログラミングみたいな記事ないかな??」

と思っていたら、Twitterにガッツリそのものの情報が流れてきました。
それが以下の記事です。

📝 参考URL: UIデザインのための心理学:33の法則・原則(実例つき)

この記事はホントに良記事で、しかも実例があるので分かりやすいです!(これも優しさですね😄✨)

そして、せっかく良い記事に出会ったのだから、気に入ったものをVue 3でコンポーネント化し、再利用できるようにしてみたいなと考えました。

そこで❗

今回は以下3つのコンポーネントを古長克彦さんの記事を参考にして作ってみたいと思います。(パクリ、というかオマージュと思っていただけたら嬉しいです😂)

  • よく使う項目 & それ以外に分割したセレクトボックス
  • 履歴が分かる検索ボックス
  • 送信中がわかるボタン

ぜひ何かの参考になりましたら嬉しいです😄✨

(なお、ご本人さんはこんなブログ見てないでしょうが、良記事に感謝いたします!)

「ホントの優しさ、探し中」

開発環境: Vue 3

よく使う項目 & それ以外に分割したセレクトボックス

よく使う項目を上部に表示し、その他は下部に表示するセレクトボックスです。

📝 デモページはこちら

では、いきなり実際のコードです。

<html>
<head>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div id="app" class="p-5">

    <h1 class="mb-4">よく使う項目 & それ以外に分割したセレクトボックス</h1>
    <div class="row">
        <div class="col-md-3">
            <v-serial-position-select
                class="form-select"
                v-model="selectedValue"
                :primary-ids="primaryIds"
                :options="options">
            </v-serial-position-select>
            <div class="mt-2">
                選択中の ID: <span v-text="selectedValue"></span>
            </div>
        </div>
    </div>

</div>
<script src="https://unpkg.com/vue@3.2.31/dist/vue.global.prod.js"></script>
<script>

    const { createApp, ref, computed } = Vue;

    // 系列位置効果
    const serialPositionSelectComponent = {
        props: {
            modelValue: {
                default: ''
            },
            options: {
                type: Array,
                default: () => []
            },
            primaryIds: {
                type: Array,
                default: () => []
            }
        },
        setup(props, { emit }) {

            const primaryIds = ref(props.primaryIds);
            const primaryOptions = computed(() => {

                return props.options.filter(option => {

                    return primaryIds.value.includes(option.id);

                });

            });
            const normalOptions = computed(() => {

                return props.options.filter(option => {

                    return !primaryIds.value.includes(option.id);

                });

            });
            const selectedValue = computed({
                get() {

                    return props.modelValue;

                },
                set(value) {

                    emit('update:modelValue', value);

                }
            });

            return {
                primaryOptions,
                normalOptions,
                selectedValue
            }
        },
        template: `
            <select v-model="selectedValue">
                <option value="" selected></option>
                <option v-if="primaryOptions.length" disabled>よく使う項目</option>
                <option v-for="option in primaryOptions" :value="option.id" v-text="option.label"></option>
                <option v-if="primaryOptions.length" disabled>---</option>
                <option v-for="option in normalOptions" :value="option.id" v-text="option.label"></option>
            </select>
        `
    };

    createApp({
        setup() {

            // 基本データ
            const options = [
                { id: 1, label: '山田太郎' },
                { id: 2, label: '佐藤二郎' },
                { id: 3, label: '田中三郎' },
                { id: 4, label: '山本四郎' },
                { id: 5, label: '藤原五郎' },
            ];

            // 系列位置効果
            const primaryIds = [1, 2, 5];
            const selectedValue = ref();

            return {
                options,
                primaryIds,
                selectedValue
            }
        }
    })
    .component('v-serial-position-select', serialPositionSelectComponent)
    .mount('#app');

</script>
</body>
</html>

この中で特徴的なのが、computedgettersetterを使っているところです。

こうすることで、watchでデータの変更を監視しなくてもよくなります。

あとは、primaryIdsの中身で選択肢を分割し表示しているだけです。

実際には以下のようになりました。

やはり、「よく使う項目」があるだけで使う側からすると「気持ち&物理的エネルギー」を減らすことができると思います。

実際の使い方としては、常連さんの名前やよく使う材料などを上部に配置してあげるような運用がいいんじゃないでしょうか。

履歴が分かる検索ボックス

過去に検索したキーワードがすぐ近くに表示されていて、クリックするだけで入力されれば「また同じ入力」を減らすことができます。

📝 デモページはこちら

なお、いろいろな運用があるとは思いますが、今回はブラウザだけで完結できるようlocalSotrageを使っています。(本来は検索して結果が存在していたキーワードだけ表示すべきかも、ですね🤔)

では、実際のコードです。

<html>
<head>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div id="app" class="p-5">

    <h1 class="mb-4">履歴が分かる検索ボックス</h1>
    <div class="row mb-3">
        <div class="col-md-4">
            <label class="mb-1">キーワード</label>
            <v-history-search-input
                storage-key="unique-key-here"
                v-model="searchKeyword"
                @search="onSearch">
            </v-history-search-input>
        </div>
    </div>
</div>
<script src="https://unpkg.com/vue@3.2.31/dist/vue.global.prod.js"></script>
<script>

    const { createApp, ref, computed, onMounted } = Vue;

    const historySearchInputComponent = {
        props: {
            modelValue: {
                type: String,
                default: '',
            },
            storageKey: {
                type: String,
                default: '',
            },
        },
        setup(props, { emit }) {

            // 履歴ブロック
            const storageKey = props.storageKey;

            if(!storageKey) {

                console.warn('Storage key is not defined.'); // もし storageKey が指定されていなければ、警告を表示

            }

            const getSavedKeywords = () => {

                const json = localStorage.getItem(storageKey) || '[]';
                return JSON.parse(json)

            };
            const savedKeywords = ref(getSavedKeywords());
            const saveKeyword = keyword => {

                if(keyword !== '' && !savedKeywords.value.includes(keyword)) {

                    const allKeywords = [keyword, ...savedKeywords.value];
                    const slicedKeywords = allKeywords.slice(0, 5); // 過去 5 件まで保持
                    const keywordJson = JSON.stringify(slicedKeywords);
                    localStorage.setItem(storageKey, keywordJson);

                    savedKeywords.value = getSavedKeywords();

                }

            };
            const setKeyword = keyword => {

                searchKeyword.value = keyword;

            };

            // 検索ブロック
            const searchKeyword = computed({
                get() {

                    return props.modelValue;

                },
                set(value) {

                    emit('update:modelValue', value);

                },
            });
            const onSearch = () => { // 検索したとき

                const value = searchKeyword.value;
                emit('search', value);
                saveKeyword(value);

            };

            return {
                savedKeywords,
                setKeyword,
                searchKeyword,
                onSearch,
            }

        },
        template: `
            <div class="input-group">
                <input type="text" class="form-control" v-model="searchKeyword" @keypress.enter="onSearch">
                <button class="btn btn-primary" type="button" @click="onSearch">検索</button>
            </div>
            <div class="small p-1" v-if="savedKeywords.length">
                <strong>検索履歴:</strong>
                <span class="ps-2" v-for="keyword in savedKeywords">
                    <a href="#" v-text="keyword" @click.prevent="setKeyword(keyword)"></a>
                </span>
            </div>
        `,
    };

    createApp({
        setup() {

            const searchKeyword = ref();
            const onSearch = value => {

                console.log(value);
                // ここで Ajax などを実行する

            };

            return {
                searchKeyword,
                onSearch,
            }

        }
    })
    .component('v-history-search-input', historySearchInputComponent)
    .mount('#app');

</script>
</body>
</html>

この中で重要なのが、allKeywords.slice(0, 5);で「最大5件まで」検索キーワードを保存している部分です。(さすがにたくさん検索履歴があると、逆にわかりにくくなってしまいますので…)

また、!savedKeywords.value.includes(keyword)で「すでに同じキーワードが登録済み」の場合は保存はしないようにしていることにも注目してください。

実際の結果はこうなりました。

送信中がわかるボタン

これは私も過去に使ったことがあるのですが、「クリックすると処理中だけ表示が変わる」ボタンです。

📝 デモページはこちら

なお、通常はアイコンにはクルクル回るようなものを使いますが、今回はテストですので、絵文字の「⏳」を使ってます。

では、実際のコードです。

<html>
<head>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div id="app" class="p-5">
    <h1 class="mb-4">送信中がわかるボタン</h1>
    <div class="row mb-3">
        <div class="col-md-4">
            <v-loadable_button class="btn-lg" v-model:loading="loading" @click="onClick">
                保存する
            </v-loadable_button>
        </div>
    </div>
</div>
<script src="https://unpkg.com/vue@3.2.31/dist/vue.global.prod.js"></script>
<script>

    const { createApp, ref, watch } = Vue;

    const loadableButtonComponent = {
        props: {
            loading: {
                type: Boolean,
                default: false
            },
        },
        setup(props, { emit }) {

            const isLoading = ref(props.loading);
            watch(() => props.loading, newValue => { // loading の変更を監視する

                isLoading.value = newValue;

            });
            const onClick = () => {

                isLoading.value = true;
                emit('click');
                emit('update:loading', true);

            };

            return {
                isLoading,
                onClick
            }

        },
        template: `
            <button type="button" class="btn btn-primary" :disabled="isLoading" @click="onClick">
                <span class="rotate" v-if="isLoading">&#x23F3;</span><slot>送信する</slot>
            </button>
        `,
    };

    createApp({
        setup() {

            const loading = ref(false);
            const onClick = () => {

                // Ajax 送信のかわりにタイマーをセット
                setTimeout(() => {

                    loading.value = false;

                }, 1000);

            };

            return {
                loading,
                onClick,
            }

        }
    })
    .component('v-loadable_button', loadableButtonComponent)
    .mount('#app');

</script>
</body>
</html>

この中で特徴的なのが、<slot>...</slot>の部分です。

こうすることで、ボタンのテキストを変更することができるようになります。

<v-loadable_button v-model:loading="loading" @click="onClick">
    入力データを送信する
</v-loadable_button>

では、実際の画像です。

送信中は、ボタンがdisabledにもなるのでクリックができなくなり、さらにアイコンがあるので「現在送信中!」というのが分かりやすくなります。

基本的にAjaxでデータ送信して保存する場合は、このボタンを使うのがいいかもしれません。

企業様へのご提案

今回のように「ユーザビリティ」を向上させることによって作業効率もあがり、何よりスタッフさんたちが「脳の省エネ化」ができますので、より必要な部分にエネルギーを集中してもらうことができるんじゃないでしょうか。

もしこういったご希望をお持ちでしたら、お気軽にお問い合わせからご依頼ください。

お待ちしております。😄✨

開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

ということで、今回は思いがけずTwitterでいい情報を発見したことが発端で記事を書いてみました。

私としましても、「これはいい❗」と思う技術をいくつも知ることになり勉強になりました👍

ちなみに、「運がいい人」は「人付き合いが多い人」だそうですが、Twitterもその条件のうちの1つじゃないかと感じています。

知っているのと知らないのでは、損得が大きく変わってくるということですね。

せめて「損はしないように」だけはしたいもんです(人見知りするので、切実😂)

ではでは〜❗

「buzz the bears いい曲かくね〜」

このエントリーをはてなブックマークに追加       follow us in feedly