001 /*
002 * Copyright 2006 Mat Gessel <mat.gessel@gmail.com>
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
005 * use this file except in compliance with the License. You may obtain a copy of
006 * the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
012 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
013 * License for the specific language governing permissions and limitations under
014 * the License.
015 */
016 package asquare.gwt.tk.client.ui;
017
018 import java.util.List;
019 import java.util.Vector;
020
021 import asquare.gwt.tk.client.ui.behavior.*;
022 import asquare.gwt.tk.client.ui.commands.FocusCommand;
023 import asquare.gwt.tk.client.util.DomUtil;
024
025 import com.google.gwt.core.client.GWT;
026 import com.google.gwt.user.client.*;
027 import com.google.gwt.user.client.ui.HasFocus;
028 import com.google.gwt.user.client.ui.Widget;
029 import com.google.gwt.user.client.ui.impl.FocusImpl;
030
031 /**
032 * A modal dialog featuring:
033 * <ul>
034 * <li>an optional caption</li>
035 * <li>support for widgets in the caption</li>
036 * <li>a background "{@link GlassPanel}" which blocks user interaction with
037 * the page (also stylable for the "light box" effect). </li>
038 * <li>automatic centering in browser's main viewport (regardless of document
039 * scroll)</li>
040 * <li>resizable: if you set the size of the content area the layout will
041 * maintain integrity on all platforms & quirks/strict modes</li>
042 * <li>minimum size specification for content area (optional, default is 200 x
043 * 75px)</li>
044 * <li>focus containment & management</li>
045 * <li>option to restore focus to triggering widget when dialog is hidden</li>
046 * </ul>
047 * The caption has the following properties:
048 * <ul>
049 * <li>inserted at top of dialog</li>
050 * <li>can be dragged to move the dialog</li>
051 * <li>disallows text selection</li>
052 * <li>has the <code>Caption</code> style name</li>
053 * </ul>
054 * <h3>Usage Notes</h3>
055 * <ul>
056 * <li>When adding a Panel, the panel's child widgets are not automatically
057 * added to the {@link #getFocusModel() focus model}. </li>
058 * <li>Call {@link #removeController(Controller)} with
059 * <code>{@link TabFocusController}.class</code> to disable built-in focus management.</li>
060 * <li>{@link #setWidth(String)} can result in a dialog which is wider than the
061 * caption. Use {@link #setContentWidth(String)} instead. </li>
062 * <li>IE6 ignores table cell heights in strict mode. This means that you can't
063 * set the dialog height and use "1px" to force minimum caption height.<br/>
064 * Workaround: set the height of the content cell and let the dialog auto-size.</li>
065 * <li>Opera: dialog does not receive keystrokes unless the cursor is over the
066 * dialog or a child of the dialog is focused.</li>
067 * </ul>
068 * <h3>CSS Style Rules</h3>
069 * <ul class='css'>
070 * <li>.tk-ModalDialog { the dialog itself }</li>
071 * <li>.tk-ModalDialog-glassPanel { the background panel behind the dialog }</li>
072 * <li>.tk-ModalDialog .Caption { the caption }</li>
073 * <li>.tk-ModalDialog-content { the content area }</li>
074 * <li>.tk-ModalDialog-dragging { applied to the dialog whilst dragging }</li>
075 * </ul>
076 * <h3>Example</h3>
077 *
078 * <pre>
079 * final Button showDialogButton = new Button("Focus management");
080 * showDialogButton.addClickListener(new ClickListener()
081 * {
082 * public void onClick(Widget sender)
083 * {
084 * final ModalDialog inputDialog = new ModalDialog();
085 * inputDialog.setCaption("Input", false);
086 * inputDialog.add(new Label("Enter a value"));
087 * inputDialog.add(new TextBox());
088 * inputDialog.add(new Button("OK", new ClickListener()
089 * {
090 * public void onClick(Widget sender)
091 * {
092 * inputDialog.hide();
093 * }
094 * }));
095 *
096 * inputDialog.show(showDialogButton);
097 * }
098 * });
099 * </pre>
100 */
101 public class ModalDialog extends CPopupPanel
102 {
103 public static final String STYLENAME_DIALOG = "tk-ModalDialog";
104 public static final String STYLENAME_GLASSPANEL = "tk-ModalDialog-glassPanel";
105 public static final String STYLENAME_CAPTION = "Caption";
106 public static final String STYLENAME_CONTENT = "tk-ModalDialog-content";
107 public static final String STYLENAME_DRAGGING = "tk-ModalDialog-dragging";
108
109 protected static final FocusImpl s_focusImpl = (FocusImpl) GWT.create(FocusImpl.class);
110
111 private final Element m_focusable = s_focusImpl.createFocusable();
112 private final GlassPanel m_glassPanel = new GlassPanel();
113 private final RowPanel m_panel = new RowPanel();
114 private final Element m_contentTd;
115
116 private FocusModel m_focusModel;
117 private CaptionWrapper m_caption = null;
118 private int m_minContentWidth = 200;
119 private int m_minContentHeight = 75;
120 private HasFocus m_focusOnCloseWidget;
121
122 public ModalDialog()
123 {
124 setFocusModel(new FocusModel());
125 setStyleName(STYLENAME_DIALOG);
126 DOM.appendChild(getElement(), m_focusable);
127 m_glassPanel.addStyleName(STYLENAME_GLASSPANEL);
128 m_panel.addCell();
129 m_panel.addCellStyleName(STYLENAME_CONTENT);
130 m_contentTd = m_panel.getCellElement(0);
131 super.setWidget(m_panel);
132 }
133
134 /*
135 * (non-Javadoc)
136 * @see asquare.gwt.tk.client.ui.CPopupPanel#createControllers()
137 */
138 protected List createControllers()
139 {
140 List result = new Vector();
141 result.add(GWT.create(PositionDialogController.class));
142 result.add(GWT.create(InitializeFocusController.class));
143 result.add(GWT.create(TabFocusController.class));
144 result.add(GWT.create(FocusOnCloseController.class));
145 return result;
146 }
147
148 /**
149 * A factory method which gives a subclass the opportunity to override default
150 * controller creation.
151 *
152 * @return a List with 0 or more controllers, or <code>null</code>
153 */
154 protected List createCaptionControllers()
155 {
156 List result = new Vector();
157 result.add(PreventSelectionController.getInstance());
158 result.add(new DragStyleController(this, STYLENAME_DRAGGING));
159 result.add(new DragController(new DragPopupGesture(this)));
160 return result;
161 }
162
163 /**
164 * Get the focus model for this dialog.
165 */
166 public FocusModel getFocusModel()
167 {
168 return m_focusModel;
169 }
170
171 /**
172 * Set the focus model for this dialog.
173 */
174 public void setFocusModel(FocusModel focusModel)
175 {
176 m_focusModel = focusModel;
177 TabFocusController tabFocusController = (TabFocusController) getController(TabFocusController.class);
178 if (tabFocusController != null)
179 {
180 tabFocusController.setModel(focusModel);
181 }
182 }
183
184 /**
185 * Get the minimum height of the content panel.
186 *
187 * @return the minimum height in pixels
188 */
189 public int getContentMinHeight()
190 {
191 return m_minContentHeight;
192 }
193
194 /**
195 * Set the minimum height of the content panel. The default is <code>75 px</code>.
196 * Set to <code>0</code> to disable this feature.
197 *
198 * @param minHeight the minimum height in pixels
199 */
200 public void setContentMinHeight(int minHeight)
201 {
202 this.m_minContentHeight = minHeight;
203 }
204
205 /**
206 * Get the minimum width of the content panel.
207 *
208 * @return the minimum width in pixels
209 */
210 public int getContentMinWidth()
211 {
212 return m_minContentWidth;
213 }
214
215 /**
216 * Set the minimum width of the content panel. The default is <code>200 px</code>.
217 * Set to <code>0</code> to disable this feature.
218 *
219 * @param minWidth the minimum width in pixels
220 */
221 public void setContentMinWidth(int minWidth)
222 {
223 this.m_minContentWidth = minWidth;
224 }
225
226 /**
227 * Set the desired width of the content panel. The minimum width property
228 * will take precedence if applicable.
229 *
230 * @param width the width in CSS measurements
231 */
232 public void setContentWidth(String width)
233 {
234 DOM.setAttribute(m_contentTd, "width", width);
235 }
236
237 /**
238 * Set the desired height of the content panel. The minimum height property
239 * will take precedence if applicable.
240 *
241 * @param height the height in CSS measurements
242 */
243 public void setContentHeight(String height)
244 {
245 DOM.setAttribute(m_contentTd, "height", height);
246 }
247
248 /**
249 * Get the actual width of the content panel. This does not work until the
250 * dialog has been shown.
251 *
252 * @return the width in pixels
253 */
254 public int getContentOffsetWidth()
255 {
256 return DOM.getIntAttribute(m_contentTd, "offsetWidth");
257 }
258
259 /**
260 * Get the actual height of the content panel. This does not work until the
261 * dialog has been shown.
262 *
263 * @return the height in pixels
264 */
265 public int getContentOffsetHeight()
266 {
267 return DOM.getIntAttribute(m_contentTd, "offsetHeight");
268 }
269
270 /**
271 * Adds a widget to the content area of this dialog. Multiple widgets may be
272 * added. The widget will be added to the focus model if it implements
273 * HasFocus and does not have a tabIndex < 0.
274 *
275 * @param w a widget
276 */
277 public void add(Widget w)
278 {
279 // pre: content row is created and is last row
280 m_panel.addWidget(w, false);
281
282 if (w instanceof HasFocus)
283 {
284 m_focusModel.add((HasFocus) w);
285 }
286 }
287
288 /**
289 * Not supported. Use {@link #add(Widget)} instead.
290 *
291 * @throws UnsupportedOperationException
292 */
293 public void setWidget(Widget w)
294 {
295 throw new UnsupportedOperationException();
296 }
297
298 /**
299 * Removes a widget from the content area of this dialog. Do not use for
300 * caption widget. Use {@link #setCaption(Widget) setCaption(null)} instead.
301 *
302 * @param w the widget to remove
303 * @throws IllegalArgumentException if <code>w</code> is in the caption.
304 */
305 public boolean remove(Widget w)
306 {
307 if (m_caption != null && w == m_panel.getWidgetAt(0, 0))
308 throw new IllegalArgumentException();
309
310 if (w instanceof HasFocus)
311 {
312 m_focusModel.remove((HasFocus) w);
313 }
314
315 // pre: content row is created and is last row
316 return m_panel.remove(w, false);
317 }
318
319 /**
320 * Sets the contents of the caption to the specified text, clearing any
321 * previous contents from the caption.
322 *
323 * @param text the caption text
324 * @param asHtml true to treat <code>text</code> as html
325 * @see #setCaption(Widget)
326 */
327 public void setCaption(String text, boolean asHtml)
328 {
329 if (m_caption != null)
330 {
331 clearCaption();
332 }
333 createCaption();
334 if (asHtml)
335 {
336 DOM.setInnerHTML(m_panel.getCellElement(0), text);
337 }
338 else
339 {
340 DOM.setInnerText(m_panel.getCellElement(0), text);
341 }
342 }
343
344 /**
345 * Set a widget as the sole child of the caption, clearing any
346 * previous contents from the caption.
347 *
348 * @param w a widget
349 */
350 public void setCaption(Widget w)
351 {
352 if (m_caption != null)
353 {
354 clearCaption();
355 }
356 if (w != null)
357 {
358 createCaption();
359 m_panel.addWidgetTo(w, 0);
360 }
361 }
362
363 /*
364 * pre: m_caption == null
365 */
366 private void createCaption()
367 {
368 m_panel.insertCell(0);
369 m_caption = new CaptionWrapper(m_panel.getCellElement(0), createCaptionControllers());
370 if (isAttached())
371 {
372 m_caption.onAttach();
373 }
374 }
375
376 /*
377 * pre: m_caption != null
378 */
379 private void clearCaption()
380 {
381 if (isAttached())
382 {
383 m_caption.onDetach();
384 }
385 m_caption = null;
386 m_panel.removeCell(0);
387 }
388
389 /**
390 * Get the element that forms the content area of the dialog.
391 */
392 public Element getContentElement()
393 {
394 return m_contentTd;
395 }
396
397 /**
398 * Get the GlassPanel which is displayed behind the dialog.
399 */
400 public GlassPanel getGlassPanel()
401 {
402 return m_glassPanel;
403 }
404
405 /**
406 * Get the widget which will be focused after the dialog is closed. This
407 * property is only available while the dialog is visible.
408 *
409 * @return a widget or <code>null</code>
410 */
411 public HasFocus getFocusOnCloseWidget()
412 {
413 return m_focusOnCloseWidget;
414 }
415
416 /**
417 * Shows the glasspanel and dialog then focuses the widget selected in
418 * the focus model.
419 */
420 public void show()
421 {
422 show(null);
423 }
424
425 /**
426 * Shows the glasspanel and dialog then focuses the widget selected in
427 * the focus model.
428 *
429 * @param focusOnCloseWidget a widget to focus after this dialog is closed
430 */
431 public void show(HasFocus focusOnCloseWidget)
432 {
433 m_focusOnCloseWidget = focusOnCloseWidget;
434 m_glassPanel.show();
435 Controller positionDialogController = getController(PositionDialogController.class);
436 if (positionDialogController != null)
437 {
438 (( PositionDialogController) positionDialogController).beforeAttach(this);
439 }
440 super.show();
441 }
442
443 /**
444 * Detaches the dialog from the DOM (it will be garbage collected if there
445 * are no references to it). Has no effect if the dialog is not showing.
446 *
447 * @see com.google.gwt.user.client.ui.PopupPanel#hide()
448 */
449 public void hide()
450 {
451 super.hide();
452 m_glassPanel.hide();
453 m_focusOnCloseWidget = null;
454 }
455
456 /*
457 * (non-Javadoc)
458 * @see com.google.gwt.user.client.ui.Widget#onAttach()
459 */
460 protected void onAttach()
461 {
462 if (isAttached())
463 return;
464
465 super.onAttach();
466
467 if (m_caption != null)
468 m_caption.onAttach();
469 }
470
471 /*
472 * (non-Javadoc)
473 * @see com.google.gwt.user.client.ui.Widget#onDetach()
474 */
475 protected void onDetach()
476 {
477 if(! isAttached())
478 return;
479
480 try
481 {
482 if (m_caption != null)
483 m_caption.onDetach();
484 }
485 finally
486 {
487 super.onDetach();
488 }
489 }
490
491 /**
492 * Provides event support for the caption element.
493 */
494 protected class CaptionWrapper extends CWidget
495 {
496 /*
497 * The fun thing about GWT is that elements are not encapsulated. We can
498 * take an element (e.g. td, div) from another container and create a
499 * fly-weight widget with it to handle events. The parent container is none
500 * the wiser. Of course, we have to call onAttach() to receive events and
501 * onDetach() to prevent memory leaks.
502 */
503 protected CaptionWrapper(Element captionElement, List controllers)
504 {
505 super(captionElement, controllers);
506 setStyleName(STYLENAME_CAPTION);
507 }
508
509 protected void onAttach()
510 {
511 super.onAttach();
512 }
513
514 protected void onDetach()
515 {
516 super.onDetach();
517 }
518 }
519
520 /**
521 * Sets the initial focus when the dialog is shown.
522 * <p>
523 * This class is not extensible because it depends on private data in the
524 * dialog. It can, however, be instantiated, added to and removed from it's
525 * parent dialog without causing problems.
526 */
527 public static final class InitializeFocusController extends ControllerAdaptor
528 {
529 public InitializeFocusController()
530 {
531 super(InitializeFocusController.class);
532 }
533
534 public void plugIn(Widget widget)
535 {
536 final ModalDialog dialog = (ModalDialog) widget;
537 HasFocus focusWidget = dialog.getFocusModel().getFocusWidget();
538 Command focusCommand;
539 if (focusWidget != null)
540 {
541 focusCommand = new FocusCommand(focusWidget);
542 }
543 else
544 {
545 focusCommand = new Command()
546 {
547 public void execute()
548 {
549 s_focusImpl.focus(dialog.m_focusable);
550 }
551 };
552 }
553 DeferredCommand.add(focusCommand);
554 }
555 }
556
557 /**
558 * A controller which focuses a widget when the dialog is hidden.
559 */
560 public static class FocusOnCloseController extends ControllerAdaptor
561 {
562 public FocusOnCloseController()
563 {
564 super(FocusOnCloseController.class);
565 }
566
567 public void unplug(Widget widget)
568 {
569 HasFocus focusOnCloseWidget = ((ModalDialog) widget).getFocusOnCloseWidget();
570 if (focusOnCloseWidget != null)
571 {
572 DeferredCommand.add(new FocusCommand(focusOnCloseWidget));
573 }
574 }
575 }
576
577 /**
578 * A controller which encapsulates dialog sizing and positioning logic.
579 * Although this class doesn't react to events, we're going to implement
580 * Controller to enable dynamic configuration via
581 * {@link ControllerSupport#getController(Class)}.
582 */
583 public static class PositionDialogController extends ControllerAdaptor
584 {
585 private int m_viewportWidth;
586 private int m_viewportHeight;
587
588 public PositionDialogController()
589 {
590 super(PositionDialogController.class);
591 }
592
593 protected int getViewportWidth()
594 {
595 return m_viewportWidth;
596 }
597
598 protected void setViewportWidth(int centerX)
599 {
600 m_viewportWidth = centerX;
601 }
602
603 protected int getViewportHeight()
604 {
605 return m_viewportHeight;
606 }
607
608 protected void setViewportHeight(int centerY)
609 {
610 m_viewportHeight = centerY;
611 }
612
613 public void plugIn(Widget widget)
614 {
615 afterAttach((ModalDialog) widget);
616 }
617
618 protected int applyMinWidthConstraint(ModalDialog dialog, int contentWidth)
619 {
620 return Math.max(dialog.getContentMinWidth(), contentWidth);
621 }
622
623 protected int applyMaxWidthConstraint(ModalDialog dialog, int contentWidth)
624 {
625 return Math.min(getViewportWidth() / 2, contentWidth);
626 }
627
628 protected int applyMinHeightConstraint(ModalDialog dialog, int contentHeight)
629 {
630 return Math.max(dialog.getContentMinHeight(), contentHeight);
631 }
632
633 /**
634 * Template method for updating the content width.
635 * <ul>
636 * <li>post: the dialog content width (style attribute) will be updated
637 * <li>post: the <code>dialogWidth</code> property will be finalized
638 * </ul>
639 */
640 protected int updateContentWidth(ModalDialog dialog, int contentWidth)
641 {
642 int dialogWidth = dialog.getOffsetWidth();
643 int contentWidthInitial = dialog.getContentOffsetWidth();
644
645 if (contentWidth != contentWidthInitial)
646 {
647 /*
648 * Note: setting the content width does *not* result in
649 * immediate re-layout of parent dialog. The dialog width
650 * property will be unchanged if we refetch it.
651 */
652 dialog.setContentWidth(contentWidth + "px");
653
654 /*
655 * Refetch the content width. The non-reflowable content (e.g. a
656 * wide image or long TextBox) may force a width greater than
657 * the intended value. This returns the old value in IE6.
658 */
659 contentWidth = dialog.getContentOffsetWidth();
660
661 /*
662 * The change to the content width will result in a change in the
663 * overall dialog's width. Try to predict the width after the
664 * pending re-layout.
665 */
666 final int padding = dialogWidth - contentWidthInitial;
667 dialogWidth = contentWidth + padding;
668 }
669
670 return dialogWidth;
671 }
672
673 /**
674 * Template method for updating the content height.
675 * <ul>
676 * <li>post: the dialog content height (style attribute) will be updated
677 * <li>post: the <code>dialogHeight</code> property will be finalized
678 * </ul>
679 */
680 protected int updateContentHeight(ModalDialog dialog, int contentHeight)
681 {
682 int dialogHeight = dialog.getOffsetHeight();
683 int contentHeightInitial = dialog.getContentOffsetHeight();
684
685 if (contentHeight != contentHeightInitial)
686 {
687 /*
688 * Refetch the content height. The non-reflowable content
689 * may force a height greater than the intended value.
690 */
691 contentHeight = dialog.getContentOffsetHeight();
692
693 /*
694 * The change to the content height will result in a change in the
695 * overall dialog's height. Try to predict the height after the
696 * pending re-layout.
697 */
698 final int padding = dialogHeight - contentHeightInitial;
699 dialogHeight = contentHeight + padding;
700 }
701
702 return dialogHeight;
703 }
704
705 /**
706 * Template method for setting the dialog's final position. This
707 * implementation prevents the dialog being positioned above or left of
708 * the origin.
709 *
710 * @param dialog
711 * @param dialogWidth
712 * @param dialogHeight
713 */
714 protected void setDialogPosition(ModalDialog dialog, int dialogWidth, int dialogHeight)
715 {
716 int left = DomUtil.getViewportScrollX() + getViewportWidth() / 2 - dialogWidth / 2;
717 int top = DomUtil.getViewportScrollY() + getViewportHeight() / 2 - dialogHeight / 2;
718
719 // set the position
720 dialog.setPopupPosition((left < 0) ? 0 : left, (top < 0) ? 0 : top);
721 }
722
723 public void beforeAttach(ModalDialog dialog)
724 {
725 /*
726 * Attaching the dialog sometimes temporarily add a scroll bar,
727 * throwing off the viewport dimensions. This can happen even if a
728 * scroll bar is never displayed.
729 */
730 setViewportWidth(DomUtil.getViewportWidth());
731 setViewportHeight(DomUtil.getViewportHeight());
732
733 /*
734 * Guard against flicker when repositioning dialog.
735 * This may not be necessary, but it can't hurt.
736 */
737 DomUtil.setStyleAttribute(dialog, "visibility", "hidden");
738
739 /*
740 * This should eliminate scrollbar flicker in Opera/FF[Mac] unless
741 * the dialog height > viewport height.
742 */
743 dialog.setPopupPosition(0, 0);
744 }
745
746 public void afterAttach(ModalDialog dialog)
747 {
748 int dialogWidth, dialogHeight;
749
750 /**
751 * Apply width constraints
752 * Get/estimate dialog width.
753 */
754 dialogWidth = (updateContentWidth(dialog, applyMinWidthConstraint(dialog, applyMaxWidthConstraint(dialog, dialog.getContentOffsetWidth()))));
755
756 /*
757 * Apply height constraint last because width constraints
758 * may have changed content height.
759 * Get/estimate dialog height.
760 */
761 dialogHeight = updateContentHeight(dialog, applyMinHeightConstraint(dialog, dialog.getContentOffsetHeight()));
762
763 setDialogPosition(dialog, dialogWidth, dialogHeight);
764 DomUtil.setStyleAttribute(dialog, "visibility", "visible");
765 }
766 }
767
768 public static class PositionDialogControllerIE6 extends PositionDialogController
769 {
770 protected int updateContentWidth(ModalDialog dialog, int contentWidth)
771 {
772 int dialogWidth = dialog.getOffsetWidth();
773
774 if (contentWidth != dialog.getContentOffsetWidth())
775 {
776 dialog.setContentWidth(contentWidth + "px");
777
778 /*
779 * This magic call forces IE to update the layout.
780 */
781 dialogWidth = dialog.getOffsetWidth();
782 }
783
784 return dialogWidth;
785 }
786 }
787 }