Przejdź do treści

Blogasek ethanaka

Artykuły

Sposób na menu

Wiele razy pojawia się na listach dyskusyjnych dotyczących tworzenia stron www pytanie o menu. Niestety, czasem udzielane odpowiedzi nie są - oględnie mówiąc - zbyt fachowe. Proponowane rozwiązania bywają albo zbyt skomplikowane, albo kłócą się z pojęciem dostepności (np. używanie display:none czy dynamiczne dodawanie/usuwanie elementów w zdarzeniach mouseover i mouseout). Dlatego spróbowałem stworzyć proste, a jednocześnie w miarę uniwersalne rozwiązanie.

Oczywiście - nie jest to gotowiec do bezmyślnego kopiowania. Trochę trzeba będzie zrobić samemu - ale mam nadzieję, że może to służyć przynajmniej jako inspiracja.

Załóżmy typową strukturę menu:

<ul>
    <li><a href="...">Pozycja</a>
    <ul>
        <li><a href="...">Subpozycja</a></li>
        ...
    </ul>
    </li>
    ....
</ul>

Rozpocznijmy od najprostszego menu opartego wyłącznie na CSS.

Najprostsze menu

Niech nasza lista ma id="menu". Zacznijmy od podstaw: menu będzie po lewej stronie (a więc pewnie float:left), drugi poziom powinien być ukryty i wyświetlany wtedy, gdy wskaźnik myszy znajdzie się na odpowiedniej pozycji poziomu pierwszego. Dodatkowo ustalmy sobie dwa podstawowe kolory: #f3e6ba (jasny) i #b56e40 (ciemny) oraz szerokość menu 8em (teksty będą krótkie). A więc:

#menu {
	list-style:none;
	margin:0;
	padding:0;
	float:left;
	background-color: #f3e6ba;
	border: solid #b56e40;
	border-width: 1px 1px 0 1px;
	line-height:100%;
}

#menu li {
	padding:0;
	margin:0;
	width:8em;
	position: relative;
}

#menu li a {
	color: black;
	text-decoration:none;
	display:block;
	padding: 0.35em 1em;
	margin:0;
	border: solid #b56e40;
	border-width: 0 0 1px 0;
	
}

#menu li a:hover,
#menu li a:focus,
#menu li a:active {
	color:white;
	background-color:#b56e40;
}

Line-height ustalamy na 100%, będziemy operować paddingami dla elementu <a />. Element <li> musi mieć position:relative, gdyż według niego będziemy ustawiać drugi poziom menu. Dłuższe też będą teksty, a więc i szerokość podrzędnego menu musi być większa. Czyli dalej:

#menu li ul {
	position:absolute;
	list-style:none;
	margin:0;
	padding:0;
	background-color: #f3e6ba;
	left:-200em;
	top:0;
	border: solid #b56e40 1px;
}

#menu li:hover ul {
	left:7.9em;
}

#menu li ul li {
	width:17em;
	white-space: nowrap;
	position: static;
}

#menu li ul a {
	border:none;
}

Element <ul> drugiego poziomu jest początkowo przesunięty poza ekran poprzez left:-200em, natomiast po najechaniu wskaźnikiem na nadrzędny element <li> przesuwamy go w prawo prawie poza menu. Prawie - bo w ten sposób zabezpieczamy się przed wszelkimi możliwymi "dziurami" między <li> a absolutnie pozycjonowanym <ul>.

Nasze menu (pomijając IE) już zaczyna działać. Możemy jednak zauważyć pewną niedogodność: otóż przy nawigacji z klawiatury pozycje drugiego poziomu są niewidoczne. Możemy temu zaradzić poprzez ustalenie pozycji linków drugiego poziomu dla :focus i :active:

#menu li ul a:focus,
#menu li ul a:active {
	position:absolute;
	left:207.9em;
	top:0;
	background-color:#b56e40;
}

Jeszcze tylko zabezpieczenie przed sytuacją, gdy wskaźnik znajdzie się nad nadrzędnym <li>:

#menu li:hover ul a:focus,
#menu li:hover ul a:active {
	position:static;
	left:0;
}

I gotowe. Nasze najprostsze menu już działa. Co prawda IE jeszcze go nie rozumie, ale tym zajmiemy się później. Na razie gotowy efekt możemy obejrzeć najprostsze menu w akcji. Podglądaczy kodu zapewne zainteresuje kod html najprostszego menu oraz arkusz CSS najprostszego menu.

Nieco komplikujemy

Nasze menu ma jedną wadę: mianowicie nawigując z klawiatury nie widzimy, na której pozycji poziomu drugiego właśnie jesteśmy. Niestety - CSS nie pozwala na stylowanie elementów nadrzędnych, ale spróbujmy stworzyć przynajmniej jakąś protezę.

Dla każdego elementu <a> drugiego poziomu musimy ustalić, na jakiej wysokości się znajduje. Zrobimy to ustalając klasy typu tn i bn, gdzie n oznacza pozycję odpowiednio od góry i od dołu. Teraz nasz kod html dla drugiego poziomu będzie wyglądać mniej więcej tak:

    <li><a href="..." class="t0 b2">Pozycja 1</a></li>
    <li><a href="..." class="t1 b1">Pozycja 1</a></li>
    <li><a href="..." class="t2 b0">Pozycja 1</a></li>
W arkuszu CSS musimy dopisać odpowiednie style dla tych klas:
#menu li ul a.t1:focus,
#menu li ul a.t1:active {padding-top:1.7em;}

#menu li ul a.t2:focus,
#menu li ul a.t2:active {padding-top:3.4em;}

#menu li ul a.t3:focus,
#menu li ul a.t3:active {padding-top:5.1em;}

#menu li ul a.b1:focus,
#menu li ul a.b1:active {padding-bottom:1.7em;}

#menu li ul a.b2:focus,
#menu li ul a.b2:active {padding-bottom:3.4em;}

#menu li ul a.b3:focus,
#menu li ul a.b3:active {padding-bottom:5.1em;}

Efekt jednak nie wygląda zbyt ciekawie. Co prawda moglibyśmy zmienić kolor tła i tekstu na jakiś mniej zjadliwy, ale jak już robić, to do końca.

Najlepiej by było, gdyby element <a> mógł mieć dwa różne bordery. Wtedy wewnętrzny górny i dolny border określałby (poprzez swoją szerokość) pozycję elementu w menu. Niestety, tak to prawie żadna przeglądarka nie potrafi. Musimy więc użyć innego sposobu.

Dodajmy do elementów jakiś dodatkowy, neutralny element umieszczony wewnątrz <a>. Wtedy tło dla <a> możemy pozostawić takie jak dla całego menu, a zmianę kolorów oraz wewnętrzne paddingi będziemy określać wewnątrz owego elementu. Najbardziej naturalny wybór to <span> - czyli kod html będzie wyglądać następująco:

    <li><a href="..." class="t0 b2"><span>Pozycja 1</span></a></li>

Zmodyfikujmy nieco nasz arkusz CSS:

#menu li ul li {
	width:auto;
	white-space: nowrap;
	position: static;
}

#menu li ul a {
	border:none;
	padding:0;
	width:17em;
}
#menu li ul a span {    
	display:block;
	padding: 0.35em 1em;

}

#menu li ul a:focus,
#menu li ul a:active {
	position:absolute;
	left:207.9em;
	top:0;
	background-color: #f3e6ba;
	border: solid #b56e40 1px;
}


#menu li ul a:focus span,
#menu li ul a:active span {
	background-color:#b56e40;
}

#menu li:hover ul a:focus,
#menu li:hover ul a:active {
	position:static;
	border:none;
}

Po dodaniu stylu usuwającego paddingi przy kolizji focus i hover mamy już całkiem niezły efekt, który możemy sobie obejrzeć w akcji - nieco komplikacji. I znów możemy podejrzeć zarówno kod html nieco skomplikowanego jak i arkusz CSS nieco skomplikowanego menu.

Na scenę wkracza JavaScript

Menu już działa, ale ma jeszcze kilka wad. Po pierwsze nie działa w IE (chociaż można by temu zaradzić aplikując jakieś ogólnodostępne skrypty typu "hover everything"), ale jak już robimy wszystko sami, zróbmy do końca.

Znów zacznijmy od założeń. Pierwszym będzie to, że skrypt przejmie całą kontrolę nad wyświetlaniem podmenu. Czyli musimy unieważnić większość utworzonych styli. Sposobów na to jest dużo, ja wybrałem najprostszy: elementowi "menu" nadałem jakąś klasę, i tę klasę dodałem do selektorów CSS określających wyświetlane menu. Natomiast w kodzie html zaraz za owym elementem umieściłem wywołanie funkcji odpowiedzialnej za inicjalizację menu, która między innymi zmienia tę klasę. A więc w skrócie:

<ul id="menu" class="nojs">
   ...
</ul>
<script type="text/javascript">
<!--
init_menu('menu');
// -->
</script>

#menu.nojs li:hover ul {
	left:7.9em;
}

/* i tak dalej */

function init_menu(menuid)
{
        if (!document.getElementById) return;
        var menu=document.getElementById(menuid);
        if (!menu) return;
        menu.className="js";
        ...
}

Jednocześnie jedynym praktycznie efektem którym będziemy sterować będzie zmiana klasy wyświetlanego podmenu, a więc do arkusza CSS dopisujemy:

#menu.js li.widoczna ul,
#menu.nojs li:hover ul {
	left:7.9em;
}

Teraz możemy zająć się pisaniem skryptu.

Zacznijmy od inicjalizacji. Do każdego elementu <li> pierwszego poziomu musimy przypisać określone akcje dla zdarzeń mouseover i mouseout (realizujące pokazywanie podmenu przy wskazaniu go myszą), a do elementów <a> akcje dla focus i blur (realizujące pokazywanie podmenu przy nawigacji z klawiatury). Stąd dalsza część funkcji init_menu będzie wyglądać następująco:

    var li;
    var alist;
    var i;

    for (li=menu.firstChild;li;li=li.nextSibling) {
        if (li.nodeName.toLowerCase() != 'li') continue;
	alist=li.getElementsByTagName('a');
	init_mouse(li);
	for (i=0;i<alist.length;i++) init_keyboard(alist[i],li);
    }

Zajmijmy się teraz samym pokazywaniem i chowaniem podmenu.

O ile reakcja na klawiaturę powinna być natychmiastowa, o tyle reakcja na mysz powinna być w niektórych przypadkach nieco opóźniona. Przede wszystkim - jeśli wskazaliśmy na element menu i rozwinęło się podmenu, wygodnie jest prowadzić myszkę nie po prostych (tzn. w prawo, uważając aby ani na moment nie zjechać z właściwej pozycji i dopiero potem w dół), ale po skosie. Dlatego w takich przypadkach opóźnienie jest wskazane.

Jak to zrealizować?

Utwórzmy dwie zmienne: visible_li w której zapamiętamy wyświetlane właśnie podmenu oraz next_li, w której będziemy pamiętać informacje o podmenu które ma być wyświetlone. Prosta funkcja realizująca wyświetlanie będzie wyglądać następująco:

var visible_li;
var next_li;
    
function show_li()
{
    if (visible_li) {
        if (visible_li != next_li) visible_li.className='';
    }
    if (next_li) {
        visible_li=next_li;
        visible_li.className='widoczna';
    }
}

Ponieważ czas opóźnienia przy wyświetlaniu powinien być mniejszy niż przy chowaniu podmenu, funkcję show_li musimy wywołać wyłącznie z timeoutu pochodzącego od ostatniego zdarzenia. Moglibyśmy bawić się w zapamiętywanie timeoutów i kasowanie ich poprzez clearTimeout, zastosujemy prostszy i bardziej odporny na błędy sposób: mianowicie z każdym timeoutem zwiążemy jakiś numer, a wykonanie uzależnimy od tego, czy dany numer jest tym związanym z ostatnim zdarzeniem. W tej sytuacji funkcja init_mouse będzie taka:

var step=0;
function init_mouse(el)
{
    el.onmouseover=function() {
        var n=++step;
        next_li=el;
        if (visible_li) setTimeout(function() {
	    if (step==n) show_li();
        },100);
	else show_li();
    }
    el.onmouseout=function() {
        var n=++step;
        next_li=null;
        setTimeout(function() {
            if (step==n) show_li();
        },300);
    }
}

Funkcja realizująca kontrolę z klawiatury będzie prostsza, bo nie musimy stosować opóźnień:

function init_keyboard(lnk,li)
{
    lnk.onfocus=function() {
        next_li=li;
        step++;
        show_li();
    }
    lnk.onblur=function() {
        next_li=null;
        step++;
        show_li();
    }
}

Nasze menu już działa, ale ma jedną bolączkę: jeśli menu było otwarte z klawiatury a następnie otwarte zostało inne poprzez akcję związaną z myszą, po zdjęciu wskaźnika myszy ginie informacja gdzie właściwie jesteśmy. Zaradzimy temu w ten sposób, że wprowadzimy jeszcze jedną zmienną focus_li w której zapamiętywać będziemy ostatnie rozwinięte z klawiatury podmenu, a przy chowaniu podmenu sprawdzimy czy przypadkiem nie należy wyświetlić innego. Modyfikacje będą bardzo proste:

var focus_li;

function show_li()
{
    if (visible_li) {
        if (visible_li != next_li) visible_li.className='';
    }
    if (next_li || focus_li) {
        visible_li=next_li || focus_li;
        visible_li.className='widoczna';
    }
}

function init_keyboard(lnk,li)
{
    lnk.onfocus=function() {
        focus_li=next_li=li;
        step++;
        show_li();
    }
    lnk.onblur=function() {
        focus_li=next_li=null;
        step++;
        show_li();
    }
}

I to wszystko. Możemy teraz zobaczyć jak się zachowuje menu w akcji. Dla wścibskich: kod HTML menu, arkusz CSS menu oraz skrypt menu.

Możliwe modyfikacje

Pokazane menu to oczywiście tylko szkieletowa konstrukcja. Pominięte zostały kwestie związane z błędami wyświetlania IE6 (tu akurat rozwiązanie zależeć może od całości projektu i nie chcę nic sugerować) czy manieryzmy Konquerora który uznaje elementy leżące zbyt daleko poza ekranem za nieistotne (tu wartość -200em należy dobrać dla konkretnej strony). Natomiast na pewno można wprowadzić następujące zmiany:

  1. dostosowanie skryptu do warunków, gdy mamy kilka menu na stronie (wywołanie z większą ilością argumentów);
  2. wyjście z sytuacji, gdy wyświetlane podmenu nie mieści się na ekranie (np. poprzez sprawdzenie czy dolna krawędź submenu nie jest poniżej ekranu i ustawienie top);
  3. dostosować style/skrypty do większej głębokości menu.
Wszelkie komentarze i sugestie mile widziane.
ethanak, 1 listopada 2007
Komentarze (10)